Compare commits

..

70 Commits

Author SHA1 Message Date
jaybe ce33ebf9b0 unit 테스트 수정
CI / test (push) Successful in 5m12s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m13s
2025-12-12 19:47:00 +09:00
jaybe 26ef92d0a3 ignore 수정,, e2e오류 수정
CI / test (push) Failing after 5m29s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-12 19:26:34 +09:00
jaybe 4840a530b6 오류 수정
CI / test (push) Failing after 5m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-12 18:04:31 +09:00
jaybe 6804665b95 테스트 수정
CI / test (push) Successful in 5m25s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m24s
2025-12-10 18:58:59 +09:00
jaybe 017d5f128e ci 오류 수정
CI / test (push) Successful in 5m25s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m20s
2025-12-10 17:04:56 +09:00
jaybe 86f28a1299 자동저장 수정
CI / test (push) Failing after 5m28s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-10 16:19:30 +09:00
jaybe f71207aeb5 i18n 적용
CI / test (push) Failing after 11m12s
CI / e2e (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled
2025-12-10 15:56:51 +09:00
jaybe 73e9bc6a1c Merge pull request 'CI: feature/app-global-theme' (#33) from feature/app-global-theme into main
CI / test (push) Successful in 5m15s
CI / e2e (push) Successful in 6m1s
CI / pr_and_merge (push) Has been skipped
2025-12-09 11:53:30 +00:00
jaybe 9c07756e42 테스트 오류 수정
CI / test (push) Successful in 5m26s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m54s
2025-12-09 20:46:11 +09:00
jaybe e615aa1227 Merge pull request 'CI: feature/app-global-theme' (#32) from feature/app-global-theme into main
CI / test (push) Successful in 5m17s
CI / e2e (push) Failing after 6m13s
CI / pr_and_merge (push) Has been skipped
2025-12-09 11:28:07 +00:00
jaybe 23a08621db 테스트 오류 수정
CI / test (push) Successful in 5m19s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m59s
2025-12-09 20:20:47 +09:00
jaybe 676e58cad7 테마 적용
CI / test (push) Failing after 5m22s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-09 18:53:21 +09:00
jaybe 3413c3e689 Merge pull request 'CI: feature/dashboard-v1' (#31) from feature/dashboard-v1 into main
CI / test (push) Successful in 4m58s
CI / e2e (push) Successful in 6m0s
CI / pr_and_merge (push) Has been skipped
2025-12-07 22:28:04 +00:00
jaybe 9d8c4538c7 대시보드, GNB 추가
CI / test (push) Successful in 4m58s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m45s
2025-12-08 06:59:52 +09:00
jaybe b40be693ee 슬러그 수정
CI / test (push) Successful in 4m56s
CI / e2e (push) Successful in 5m53s
CI / pr_and_merge (push) Has been skipped
2025-12-07 20:57:19 +09:00
jaybe da3d71d124 Merge pull request 'CI: feature/form-submissions-pages' (#30) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m49s
CI / e2e (push) Successful in 5m53s
CI / pr_and_merge (push) Has been skipped
2025-12-07 10:53:30 +00:00
jaybe 4bb4c83054 ci 수정
CI / test (push) Successful in 4m50s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m40s
2025-12-07 19:46:56 +09:00
jaybe 243083261f 폼전송 기능 수정
CI / test (push) Failing after 4m53s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-07 19:11:06 +09:00
jaybe 5e8d0f4921 Merge pull request 'CI: feature/form-submissions-pages' (#29) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m42s
CI / e2e (push) Successful in 5m41s
CI / pr_and_merge (push) Has been skipped
2025-12-07 05:05:52 +00:00
jaybe 96fa34cd86 e2e 수정
CI / test (push) Successful in 4m48s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m30s
2025-12-07 13:59:32 +09:00
jaybe 39ca7769fa Merge pull request 'CI: feature/form-submissions-pages' (#28) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m35s
CI / e2e (push) Failing after 5m40s
CI / pr_and_merge (push) Has been skipped
2025-12-07 03:07:58 +00:00
jaybe eb78359b10 테스트 오류 수정
CI / test (push) Successful in 4m38s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m30s
2025-12-07 12:01:48 +09:00
jaybe 72b027a29c e2e 오류 수정
CI / test (push) Failing after 4m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-07 11:21:09 +09:00
jaybe a385ed5de3 Merge pull request 'CI: feature/form-submissions-pages' (#27) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m38s
CI / e2e (push) Failing after 6m1s
CI / pr_and_merge (push) Has been skipped
2025-12-07 01:18:45 +00:00
jaybe 4902a0b5a9 CI빌드 오류 수정
CI / test (push) Successful in 4m48s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m13s
2025-12-07 10:12:38 +09:00
jaybe a5b432fb7b TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-07 09:52:42 +09:00
jaybe e726f43f7c Merge pull request 'CI: feature/forms-submissions' (#26) from feature/forms-submissions into main
CI / test (push) Successful in 4m41s
CI / e2e (push) Successful in 5m22s
CI / pr_and_merge (push) Has been skipped
2025-12-02 04:43:29 +00:00
jaybe 7574d3a72c CI 로직 수정
CI / test (push) Successful in 4m34s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m45s
2025-12-02 13:37:10 +09:00
jaybe 3d683d99a5 Merge pull request 'CI: feature/forms-submissions' (#25) from feature/forms-submissions into main
CI / test (push) Successful in 4m44s
CI / e2e (push) Failing after 11m27s
CI / pr_and_merge (push) Has been skipped
2025-12-02 03:41:31 +00:00
jaybe f087ae5f2c CI 수정
CI / test (push) Successful in 4m31s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m46s
2025-12-02 12:35:12 +09:00
jaybe 7cd9d1273b Merge pull request 'CI: feature/forms-submissions' (#24) from feature/forms-submissions into main
CI / test (push) Successful in 4m40s
CI / e2e (push) Failing after 1m11s
CI / pr_and_merge (push) Has been skipped
2025-12-02 03:21:27 +00:00
jaybe 5832b64d39 e2e 테스트 수정
CI / test (push) Successful in 4m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m33s
2025-12-02 12:15:05 +09:00
jaybe bd738d4bc0 Merge pull request 'CI: feature/forms-submissions' (#23) from feature/forms-submissions into main
CI / test (push) Failing after 6m59s
CI / pr_and_merge (push) Has been skipped
2025-12-02 02:33:16 +00:00
jaybe c331d5e14a 유닛테스트 수정
CI / test (push) Successful in 4m35s
CI / pr_and_merge (push) Successful in 1m44s
2025-12-02 11:27:08 +09:00
jaybe 3e223a45d4 폼 전송 TDD추가 및 암호화 적용 중
CI / test (push) Failing after 4m41s
CI / pr_and_merge (push) Has been skipped
2025-12-02 11:02:15 +09:00
jaybe d0766fca45 메뉴 순서 수정
CI / test (push) Successful in 6m6s
CI / pr_and_merge (push) Has been skipped
2025-12-01 18:30:10 +09:00
jaybe 555d00e060 Merge pull request 'CI: feature/editor-account-switch' (#22) from feature/editor-account-switch into main
CI / test (push) Successful in 6m0s
CI / pr_and_merge (push) Has been skipped
2025-12-01 09:06:14 +00:00
jaybe c3de7df252 오류 수정
CI / test (push) Successful in 4m31s
CI / pr_and_merge (push) Successful in 1m11s
2025-12-01 18:00:31 +09:00
jaybe 9e75989c5d playwright 수정
CI / test (push) Failing after 2m30s
CI / pr_and_merge (push) Has been skipped
2025-12-01 17:24:19 +09:00
jaybe dbe76503d9 Merge pull request 'CI: feature/editor-account-switch' (#21) from feature/editor-account-switch into main
CI / test (push) Failing after 25m49s
CI / pr_and_merge (push) Has been skipped
2025-12-01 07:43:35 +00:00
jaybe ed093bf04f docker제거
CI / test (push) Successful in 3m35s
CI / pr_and_merge (push) Successful in 1m25s
2025-12-01 16:38:40 +09:00
jaybe a5e846a1e8 Merge pull request 'CI: feature/editor-account-switch' (#20) from feature/editor-account-switch into main
CI / pr_and_merge (push) Has been cancelled
CI / test (push) Has been cancelled
2025-12-01 07:14:02 +00:00
jaybe 198acd8db1 CI 이미지 생성
CI / test (push) Successful in 3m41s
CI / pr_and_merge (push) Successful in 1m8s
2025-12-01 16:08:21 +09:00
jaybe 5414db8e49 Merge pull request 'CI: feature/editor-account-switch' (#19) from feature/editor-account-switch into main
CI / pr_and_merge (push) Has been cancelled
CI / test (push) Has been cancelled
2025-12-01 06:19:12 +00:00
jaybe 44869b7514 playwright install
CI / test (push) Successful in 38m8s
CI / pr_and_merge (push) Successful in 1m0s
2025-12-01 14:40:07 +09:00
jaybe 6ad731b6e2 에디터 정리 및 버그 수정
CI / test (push) Failing after 13m50s
CI / pr_and_merge (push) Has been cancelled
2025-12-01 13:34:16 +09:00
jaybe eba4b0eeee Merge pull request 'CI: feature/user-project-ownership' (#18) from feature/user-project-ownership into main
CI / test (push) Failing after 7m3s
CI / pr_and_merge (push) Has been skipped
2025-11-30 18:29:25 +00:00
jaybe 5f541e9524 회원가입, 로그인
CI / test (push) Successful in 3m36s
CI / pr_and_merge (push) Successful in 1m17s
2025-12-01 03:24:38 +09:00
jaybe 1d9548862a Merge pull request 'CI: feature/user-project-ownership' (#17) from feature/user-project-ownership into main
CI / test (push) Failing after 7m18s
CI / pr_and_merge (push) Has been skipped
2025-11-30 15:41:42 +00:00
jaybe 64d2e9d1af CI오류 수정
CI / test (push) Successful in 3m37s
CI / pr_and_merge (push) Successful in 1m8s
2025-12-01 00:37:00 +09:00
jaybe 637d834cd7 Merge pull request 'CI: feature/user-project-ownership' (#16) from feature/user-project-ownership into main
CI / test (push) Failing after 29m57s
CI / pr_and_merge (push) Has been skipped
2025-11-30 14:13:28 +00:00
jaybe 64e4a59244 사용자 로그인, 가입 처리
CI / test (push) Successful in 3m40s
CI / pr_and_merge (push) Successful in 1m8s
2025-11-30 23:08:38 +09:00
jaybe 25162e4c12 Merge pull request 'CI: feature/editor-persistence' (#15) from feature/editor-persistence into main
CI / test (push) Successful in 6m40s
CI / pr_and_merge (push) Has been skipped
2025-11-30 09:57:21 +00:00
jaybe a6004a5caf CI 정책 수정
CI / test (push) Successful in 3m18s
CI / pr_and_merge (push) Successful in 1m11s
2025-11-30 18:52:58 +09:00
jaybe 23392d6ac2 Merge pull request 'CI: feature/editor-persistence' (#14) from feature/editor-persistence into main
CI / test (push) Successful in 6m29s
CI / pr_and_merge (push) Has been skipped
2025-11-30 09:23:39 +00:00
jaybe c0ddb39562 CI 정책 변경
CI / test (push) Successful in 5m15s
CI / pr_and_merge (push) Successful in 1m6s
CI / test (pull_request) Successful in 6m25s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-30 18:07:05 +09:00
jaybe 73f42078b7 Merge pull request 'CI: feature/editor-persistence' (#13) from feature/editor-persistence into main
CI / test (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled
2025-11-30 08:39:15 +00:00
jaybe 278195772a e2e오류 수정
CI / test (push) Successful in 12m30s
CI / pr_and_merge (push) Successful in 1m6s
CI / test (pull_request) Has been cancelled
CI / pr_and_merge (pull_request) Has been cancelled
2025-11-30 17:25:44 +09:00
jaybe 0cf67b5619 리스트/에디터 스타일 정리 및 버그 수정
CI / test (push) Failing after 7m55s
CI / pr_and_merge (push) Has been skipped
2025-11-30 16:52:27 +09:00
jaybe 17bfac62ed 프로젝트 저장 및 목록
CI / test (push) Failing after 7m39s
CI / pr_and_merge (push) Has been skipped
2025-11-29 22:05:25 +09:00
jaybe 1f085bd64c 스타일 수정
CI / test (push) Failing after 44m2s
CI / pr_and_merge (push) Has been skipped
2025-11-29 15:11:49 +09:00
jaybe c9a62d479c form export 수정 및 통합테스트
CI / test (push) Successful in 49m26s
CI / pr_and_merge (push) Has been skipped
2025-11-28 14:25:30 +09:00
jaybe c7b6097bd9 Merge pull request 'CI: feature/bugfix-form-video-preview' (#12) from feature/bugfix-form-video-preview into main
CI / test (push) Successful in 53m24s
CI / pr_and_merge (push) Has been skipped
2025-11-27 17:52:44 +00:00
jaybe 6e5e229ae5 CI오류 수정-3
CI / test (push) Successful in 12m8s
CI / pr_and_merge (push) Successful in 1m18s
CI / test (pull_request) Successful in 51m49s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-28 02:39:23 +09:00
jaybe af84bcbe8d Merge pull request 'CI: feature/bugfix-form-video-preview' (#11) from feature/bugfix-form-video-preview into main
CI / test (push) Failing after 42m51s
CI / pr_and_merge (push) Has been skipped
2025-11-27 15:43:04 +00:00
jaybe 55906f9d3d CI오류 수정-2
CI / test (push) Successful in 12m12s
CI / pr_and_merge (push) Successful in 1m17s
CI / test (pull_request) Successful in 52m3s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-28 00:29:44 +09:00
jaybe 23bc96b3a1 Merge pull request 'CI: feature/bugfix-form-video-preview' (#10) from feature/bugfix-form-video-preview into main
CI / test (push) Failing after 44m0s
CI / pr_and_merge (push) Has been skipped
2025-11-27 11:42:26 +00:00
jaybe d4cf2f0225 CI오류 수정
CI / test (push) Successful in 12m10s
CI / pr_and_merge (push) Successful in 1m11s
CI / test (pull_request) Failing after 45m59s
CI / pr_and_merge (pull_request) Has been skipped
2025-11-27 20:29:08 +09:00
jaybe c5c9d17e8b 비디오 태그 및 폼 블록 정리
CI / test (push) Failing after 7m30s
CI / pr_and_merge (push) Has been skipped
2025-11-27 19:37:32 +09:00
jaybe 1405280ed3 merge: 16.1 Export 미리보기 UX main 반영
CI / test (push) Failing after 7m21s
CI / pr_and_merge (push) Has been skipped
2025-11-27 12:11:13 +09:00
878 changed files with 30886 additions and 3464 deletions
+44 -23
View File
@@ -5,9 +5,6 @@ on:
branches:
- main
- feature/**
pull_request:
branches:
- main
jobs:
test:
@@ -17,12 +14,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: |
npm ci
@@ -38,7 +29,16 @@ jobs:
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 을 사용한다.
@@ -48,23 +48,44 @@ jobs:
run: |
npm test
# Playwright 브라우저는 용량이 크기 때문에 캐시를 사용해 설치 시간을 단축한다.
- name: Cache Playwright browsers
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
e2e:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- name: Install Playwright browsers
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
run: |
npx playwright install --with-deps
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
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
run: |
npx playwright test
run: npm run e2e
pr_and_merge:
needs: test
+3
View File
@@ -12,6 +12,7 @@ node_modules/
# Playwright
playwright-report/
blob-report/
test-results/
# Docker
**/.DS_Store
@@ -22,3 +23,5 @@ blob-report/
MAIN_PLAN.md
/src/generated/prisma
uploads/
View File
+145 -2
View File
@@ -12,8 +12,11 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.19.0",
"bcryptjs": "^2.4.3",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -25,6 +28,8 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
@@ -2788,6 +2793,13 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2827,6 +2839,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/jszip": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
@@ -2837,6 +2860,13 @@
"jszip": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -3923,6 +3953,12 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -3991,6 +4027,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -4572,6 +4614,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
@@ -6638,6 +6689,28 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -6666,6 +6739,27 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7039,6 +7133,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7046,6 +7176,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
@@ -7089,6 +7225,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -7201,7 +7346,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nano-spawn": {
@@ -8332,7 +8476,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+6
View File
@@ -7,6 +7,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"start:e2e": "next start -p 3000",
"lint": "next lint",
"test": "vitest run",
"e2e": "playwright test"
@@ -18,8 +19,11 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.19.0",
"bcryptjs": "^2.4.3",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -32,6 +36,8 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jszip": "^3.4.0",
"@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
View File
View File
+6 -2
View File
@@ -1,5 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const isCI = !!process.env.CI;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
@@ -17,9 +19,11 @@ export default defineConfig({
},
],
webServer: {
command: "npm run dev",
// CI 에서는 프로덕션 빌드 서버(next start)를 대상으로 E2E 를 수행해
// dev 모드의 느린 HMR/StrictMode 2회 렌더로 인한 flakiness 를 줄인다.
command: isCI ? "npm run start:e2e" : "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
reuseExistingServer: !isCI,
timeout: 120_000,
},
});
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "userId" TEXT;
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"tokenVersion" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- 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;
+34 -2
View File
@@ -5,8 +5,8 @@
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
}
datasource db {
@@ -24,6 +24,25 @@ model Project {
updatedAt DateTime @updatedAt
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 {
@@ -42,3 +61,16 @@ enum ProjectStatus {
PUBLISHED
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())
}
+56
View File
@@ -0,0 +1,56 @@
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 },
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
// /api/auth/logout
// - 클라이언트의 pb_access 세션 쿠키를 제거해 로그아웃을 수행한다.
export async function POST(_request: Request) {
const res = NextResponse.json({ message: "로그아웃 되었습니다." }, { status: 200 });
// pb_access 쿠키를 즉시 만료시켜 로그아웃 상태로 만든다.
res.cookies.set("pb_access", "", {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return res;
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { verifyAccessToken } from "@/features/auth/authCrypto";
// /api/auth/me
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
export async function GET(request: Request) {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) {
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
}
const payload = await verifyAccessToken(token);
if (!payload) {
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
}
const user = {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
return NextResponse.json(user, { status: 200 });
}
+71
View File
@@ -0,0 +1,71 @@
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 },
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
// 대시보드 개요 API (/api/dashboard/overview)
// - 로그인한 사용자의 프로젝트/폼 제출 통계를 집계해 요약/프로젝트별 지표를 반환한다.
// - DB 스키마: Project, FormSubmission
// - 인증: pb_access 쿠키(JWT)를 검증해 현재 사용자 식별
const prisma = new PrismaClient();
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
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;
}
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
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,
};
}
interface SummaryStats {
totalProjects: number;
totalSubmissions: number;
todaySubmissions: number;
last7DaysSubmissions: number;
}
interface DailySubmissionItem {
// 일별 제출 수 그래프를 위한 집계 데이터
date: string; // YYYY-MM-DD (UTC 기준)
count: number;
}
interface ProjectStatsItem {
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: string | null;
}
// GET /api/dashboard/overview
// - 현재 로그인 사용자의 프로젝트 목록/폼 제출 내역을 조회해 통계를 계산한다.
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json(
{ message: "대시보드를 조회하려면 로그인이 필요합니다." },
{ status: 401 },
);
}
try {
// 현재 사용자 소유 프로젝트와, 해당 사용자가 소유한 제출 내역을 한 번에 로드한다.
const [projects, submissions] = await Promise.all([
prisma.project.findMany({
where: { userId: authUser.id },
select: {
id: true,
title: true,
slug: true,
status: true,
},
}),
prisma.formSubmission.findMany({
where: { userId: authUser.id },
select: {
id: true,
projectId: true,
projectSlug: true,
createdAt: true,
},
}),
]);
const totalProjects = projects.length;
const totalSubmissions = submissions.length;
// 오늘/최근 7일 기준 시각 계산
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
let todaySubmissions = 0;
let last7DaysSubmissions = 0;
for (const submission of submissions) {
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
if (createdAt >= startOfToday) {
todaySubmissions += 1;
}
if (createdAt >= sevenDaysAgo) {
last7DaysSubmissions += 1;
}
}
// 프로젝트별 통계 맵을 구성한다.
const statsByProjectId = new Map<
string,
{
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: Date | null;
}
>();
for (const project of projects) {
statsByProjectId.set(project.id, {
projectId: project.id,
title: project.title,
slug: project.slug,
status: project.status,
totalSubmissions: 0,
lastSubmissionAt: null,
});
}
for (const submission of submissions) {
const stat = submission.projectId ? statsByProjectId.get(submission.projectId) : null;
if (!stat) continue;
stat.totalSubmissions += 1;
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
if (!stat.lastSubmissionAt || createdAt > stat.lastSubmissionAt) {
stat.lastSubmissionAt = createdAt;
}
}
const projectStats: ProjectStatsItem[] = Array.from(statsByProjectId.values()).map((item) => ({
projectId: item.projectId,
title: item.title,
slug: item.slug,
status: item.status,
totalSubmissions: item.totalSubmissions,
lastSubmissionAt: item.lastSubmissionAt ? item.lastSubmissionAt.toISOString() : null,
}));
const summaryStats: SummaryStats = {
totalProjects,
totalSubmissions,
todaySubmissions,
last7DaysSubmissions,
};
// 일별 전체 제출 수를 집계해 그래프용 데이터로 변환한다.
const countsByDate = new Map<string, number>();
for (const submission of submissions) {
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const prev = countsByDate.get(dateKey) ?? 0;
countsByDate.set(dateKey, prev + 1);
}
const dailySubmissions: DailySubmissionItem[] = Array.from(countsByDate.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
return NextResponse.json(
{
summaryStats,
projectStats,
dailySubmissions,
},
{ status: 200 },
);
} catch (error: any) {
return NextResponse.json(
{
message: "대시보드 데이터를 계산하는 중 오류가 발생했습니다.",
error: error?.message,
},
{ status: 500 },
);
}
}
+342 -116
View File
@@ -81,8 +81,10 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
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 bodyBgRaw =
typeof projectConfig?.bodyBgColorHex === "string" ? projectConfig.bodyBgColorHex.trim() : "";
const bodyBgPart = bodyBgRaw ? `background-color:${bodyBgRaw};` : "";
const bodyStyle = `${bodyBgPart}margin:0;padding:0;`;
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
@@ -160,6 +162,50 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
// - fieldIdToFormId: key = 필드 블록 id (fieldIds 에 포함된 id), value = <form> 요소의 DOM id
// - formBlockIdToFormDomId: key = FormBlock id, value = <form> 요소의 DOM id
const fieldIdToFormId = new Map<string, string>();
const formBlockIdToFormDomId = new Map<string, string>();
const submitButtonIdToFormDomId = new Map<string, string>();
const requiredFieldIdSet = new Set<string>();
// 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다.
// - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1)
// - 그 외의 id 에 대해서는 pb_form_N 패턴을 사용해, form="..." 값에 우연히 "required" 같은 단어가 포함되지 않도록 한다.
let fallbackFormIndex = 1;
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;
let formDomId: string;
if (typeof block.id === "string" && /^form_\d+$/.test(block.id)) {
formDomId = `form_${block.id}`;
} else {
formDomId = `pb_form_${fallbackFormIndex++}`;
}
formBlockIdToFormDomId.set(block.id, formDomId);
for (const fieldId of fieldIds) {
if (typeof fieldId === "string" && fieldId.trim() !== "") {
fieldIdToFormId.set(fieldId, formDomId);
}
}
const submitButtonIdRaw = props.submitButtonId;
if (typeof submitButtonIdRaw === "string" && submitButtonIdRaw.trim() !== "") {
submitButtonIdToFormDomId.set(submitButtonIdRaw, formDomId);
}
const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
for (const fieldId of requiredFieldIds) {
if (typeof fieldId === "string" && fieldId.trim() !== "") {
requiredFieldIdSet.add(fieldId);
}
}
}
const renderBlock = (block: Block): string => {
if (block.type === "text") {
const props = (block.props ?? {}) as TextBlockProps;
@@ -222,6 +268,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
widthPx: props.widthPx ?? undefined,
paddingX: props.paddingX ?? undefined,
paddingY: props.paddingY ?? undefined,
fontSizeCustom: props.fontSizeCustom ?? undefined,
lineHeightCustom: props.lineHeightCustom ?? undefined,
letterSpacingCustom: props.letterSpacingCustom ?? undefined,
fillColorCustom: props.fillColorCustom ?? undefined,
strokeColorCustom: props.strokeColorCustom ?? undefined,
textColorCustom: props.textColorCustom ?? undefined,
@@ -233,9 +282,26 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
.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}`;
}
const submitFormId = submitButtonIdToFormDomId.get(block.id);
if (submitFormId) {
return `<div class="${tokens.alignClass}"><button type="submit" form="${escapeAttr(
submitFormId,
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</button></div>`;
}
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
href,
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
}
if (block.type === "video") {
@@ -316,107 +382,43 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const props = (block.props ?? {}) as FormBlockProps;
const tokens = computeFormBlockExportTokens(block, blocks);
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
const fallbackFields = tokens.fallbackFields;
const hasControllerFields = controllerFields.length > 0;
const hasFallbackFields = fallbackFields.length > 0;
// FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다.
if (!hasControllerFields && !hasFallbackFields) {
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
if (!hasControllerFields) {
return "";
}
const formParts: string[] = [];
const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : "";
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
const formId = formBlockIdToFormDomId.get(block.id) ?? `form_${block.id}`;
const method = props.method ?? "POST";
const methodAttr = ` method="${method.toLowerCase()}"`;
const actionAttr = ` action="/api/forms/submit"`;
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
const configJson = JSON.stringify(props ?? {});
const escapedConfig = escapeAttr(configJson);
if (hasControllerFields) {
for (const fieldBlock of controllerFields) {
const anyProps: any = fieldBlock.props ?? {};
const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id;
const label =
typeof anyProps.label === "string" || typeof anyProps.groupLabel === "string"
? (anyProps.label ?? anyProps.groupLabel)
: name;
const projectSlugRaw = (projectConfig?.slug ?? "").trim();
const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : "";
const textColorRaw =
typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== ""
? anyProps.textColorCustom.trim()
: "";
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
formParts.push(`<div class="pb-form-field">`);
formParts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label ?? "")}</label>`);
if (fieldBlock.type === "formInput") {
const type = anyProps.inputType === "email" ? "email" : "text";
const required = anyProps.required ? " required" : "";
formParts.push(
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
label ?? "",
)}"${required}${fieldTextStyleAttr} />`,
);
} else if (fieldBlock.type === "formSelect") {
const required = anyProps.required ? " required" : "";
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${fieldTextStyleAttr}>`);
for (const opt of options) {
formParts.push(
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
);
}
formParts.push(`</select>`);
} else if (fieldBlock.type === "formCheckbox") {
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
for (const opt of options) {
formParts.push(
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
}
} else if (fieldBlock.type === "formRadio") {
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
for (const opt of options) {
formParts.push(
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
}
}
formParts.push(`</div>`);
}
} else if (hasFallbackFields) {
for (const field of fallbackFields) {
const required = field.required ? " required" : "";
const label = field.label ?? field.name;
formParts.push(`<div class="pb-form-field">`);
formParts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
if (field.type === "textarea") {
formParts.push(
`<textarea class="pb-textarea" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(
label,
)}"${required}></textarea>`,
);
} else {
formParts.push(
`<input class="pb-input" type="${field.type}" name="${escapeAttr(
field.name,
)}" placeholder="${escapeAttr(label)}"${required} />`,
);
}
formParts.push(`</div>`);
}
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)}" />`,
);
}
formParts.push(`<button type="submit" class="pb-btn-base pb-btn-size-md pb-btn-variant-solid-primary">제출</button>`);
formParts.push(`</form>`);
return formParts.join("");
parts.push("</form>");
return parts.join("");
}
if (block.type === "divider") {
@@ -434,7 +436,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
return "";
}
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
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}>`;
@@ -446,16 +450,55 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
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 labelDisplay = props.labelDisplay ?? "visible";
const isFloating = labelDisplay === "floating";
const isControllerRequired = requiredFieldIdSet.has(block.id);
const required = props.required || isControllerRequired ? " required" : "";
const tokens = computeFormInputExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
const inputId = name;
const fieldClass = isFloating ? "pb-form-field pb-form-field--floating" : "pb-form-field";
const cssVars: string[] = [];
if (isFloating && typeof props.paddingY === "number" && props.paddingY >= 0) {
const em = props.paddingY / 16;
cssVars.push(`--pb-input-padding-y:${em}rem`);
}
// strokeColorCustom 이 있으면 플로팅 라벨 패치 색으로 사용할 CSS 변수에 전달한다.
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
cssVars.push(`--pb-input-border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
}
const fieldStyleAttr = cssVars.length > 0 ? ` style="${cssVars.join(";")}"` : "";
const placeholderBase =
typeof props.placeholder === "string" && props.placeholder.trim() !== ""
? props.placeholder.trim()
: "";
let placeholder: string;
if (isFloating) {
placeholder = " ";
} else if (placeholderBase !== "") {
placeholder = placeholderBase;
} else {
placeholder = label;
}
const labelClass = labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
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(
`<div class="${fieldClass}"${fieldStyleAttr}>`,
`<input class="pb-input" name="${escapeAttr(name)}" id="${escapeAttr(inputId)}" type="${type}" placeholder="${escapeAttr(
placeholder,
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
`<label class="${labelClass}" for="${escapeAttr(inputId)}"${tokens.labelStyleAttr}>${escapeHtml(
label,
)}"${required}${tokens.inputStyleAttr} />`,
)}</label>`,
"</div>",
].join("");
}
@@ -465,15 +508,45 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
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 labelDisplay = props.labelDisplay ?? "visible";
const isControllerRequired = requiredFieldIdSet.has(block.id);
const required = props.required || isControllerRequired ? " 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 fieldClass = "pb-form-field";
const labelLayout = props.labelLayout ?? "stacked";
const isInlineLayout = labelDisplay === "visible" && labelLayout === "inline";
const styleParts: string[] = [];
if (isInlineLayout) {
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
const gapEm = gapPx / 16;
styleParts.push("display:flex");
styleParts.push("flex-direction:row");
styleParts.push("align-items:center");
styleParts.push(`column-gap:${gapEm}em`);
}
const widthMode = props.widthMode ?? "full";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
styleParts.push(`width:${props.widthPx}px`);
}
const fieldStyleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : "";
const labelClass =
labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
const parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}>`);
parts.push(`<div class="${fieldClass}"${fieldStyleAttr}>`);
parts.push(`<label class="${labelClass}"${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>`,
@@ -495,17 +568,68 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options = Array.isArray(props.options) ? props.options : [];
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
const groupLabelDisplay = props.groupLabelDisplay ?? "visible";
const labelLayout = props.labelLayout ?? "stacked";
const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline";
const fieldStyleParts: string[] = [];
if (isInlineLayout) {
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
const gapEm = gapPx / 16;
fieldStyleParts.push("display:flex");
fieldStyleParts.push("flex-direction:row");
fieldStyleParts.push("align-items:center");
fieldStyleParts.push(`column-gap:${gapEm}em`);
}
const widthMode = props.widthMode ?? "full";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
fieldStyleParts.push(`width:${props.widthPx}px`);
}
const fieldStyleAttr =
fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : "";
const labelClass =
groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
const optionLayout = props.optionLayout ?? "stacked";
const optionsClass =
optionLayout === "inline"
? "pb-form-options pb-form-options--inline"
: "pb-form-options pb-form-options--stacked";
const optionsStyleParts: string[] = [];
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
const gapEm = props.optionGapPx / 16;
optionsStyleParts.push(`row-gap:${gapEm}em`);
if (optionLayout === "inline") {
optionsStyleParts.push(`column-gap:${gapEm}em`);
}
}
const optionsStyleAttr =
optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : "";
const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true;
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(`<div class="pb-form-field"${fieldStyleAttr}>`);
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
parts.push(`<div class="${optionsClass}"${optionsStyleAttr}>`);
options.forEach((opt: { value: string; label: string }, index: number) => {
const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : "";
parts.push(
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
)}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> <span>${escapeHtml(
opt.label,
)}</span></label>`,
);
}
});
parts.push("</div>");
parts.push("</div>");
return parts.join("");
@@ -521,17 +645,69 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options = Array.isArray(props.options) ? props.options : [];
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
const formId = fieldIdToFormId.get(block.id);
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
const groupLabelDisplay = props.groupLabelDisplay ?? "visible";
const labelLayout = props.labelLayout ?? "stacked";
const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline";
const fieldStyleParts: string[] = [];
if (isInlineLayout) {
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
const gapEm = gapPx / 16;
fieldStyleParts.push("display:flex");
fieldStyleParts.push("flex-direction:row");
fieldStyleParts.push("align-items:center");
fieldStyleParts.push(`column-gap:${gapEm}em`);
}
const widthMode = props.widthMode ?? "full";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
fieldStyleParts.push(`width:${props.widthPx}px`);
}
const fieldStyleAttr =
fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : "";
const labelClass =
groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
const optionLayout = props.optionLayout ?? "stacked";
const optionsClass =
optionLayout === "inline"
? "pb-form-options pb-form-options--inline"
: "pb-form-options pb-form-options--stacked";
const optionsStyleParts: string[] = [];
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
const gapEm = props.optionGapPx / 16;
optionsStyleParts.push(`row-gap:${gapEm}em`);
if (optionLayout === "inline") {
optionsStyleParts.push(`column-gap:${gapEm}em`);
}
}
const optionsStyleAttr =
optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : "";
const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true;
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(`<div class="pb-form-field"${fieldStyleAttr}>`);
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
parts.push(`<div class="${optionsClass}"${optionsStyleAttr}>`);
options.forEach((opt: { value: string; label: string }, index: number) => {
const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : "";
parts.push(
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
)}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> <span>${escapeHtml(
opt.label,
)}</span></label>`,
);
}
});
parts.push("</div>");
parts.push("</div>");
return parts.join("");
@@ -561,9 +737,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const sectionStyleAttr =
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
const innerWrapperStyleAttr =
tokens.innerWrapperStyleParts.length > 0 ? ` style="${tokens.innerWrapperStyleParts.join(";")}"` : "";
const columnsStyleAttr =
tokens.columnsStyleParts.length > 0 ? ` style="${tokens.columnsStyleParts.join(";")}"` : "";
bodyParts.push(
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"${innerWrapperStyleAttr}><div class="pb-section-columns"${columnsStyleAttr}>`,
);
for (const column of columns) {
@@ -674,7 +854,53 @@ export async function POST(request: Request) {
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
}
const mainJs = `console.log("Page Builder static export loaded");`;
const mainJs = [
"(function(){",
" function pbInitFormControllers(){",
" var forms = document.querySelectorAll(\"form.pb-form-controller\");",
" if (!forms || !forms.length) { return; }",
" forms.forEach(function(form){",
" form.addEventListener(\"submit\", function(event){",
" if (!form) { return; }",
" if (event && typeof event.preventDefault === 'function') { event.preventDefault(); }",
" var formData = new FormData(form);",
" var configInput = form.querySelector('input[name=\"__config\"]');",
" var config = null;",
" if (configInput && configInput.value) {",
" try { config = JSON.parse(configInput.value); } catch (e) { config = null; }",
" }",
" var action = form.getAttribute('action') || '/api/forms/submit';",
" fetch(action, { method: 'POST', body: formData })",
" .then(function(res){ return res.json().catch(function(){ return {}; }); })",
" .then(function(data){",
" var ok = data && data.ok;",
" var message = data && data.message;",
" if (ok) {",
" var success = message || (config && config.successMessage) || '성공적으로 전송되었습니다.';",
" if (typeof alert === 'function') { alert(success); }",
" try { form.reset(); } catch (e) {}",
" } else {",
" var errorMsg = message || (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
" if (typeof alert === 'function') { alert(errorMsg); }",
" }",
" })",
" .catch(function(){",
" var errorMsg = (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
" if (typeof alert === 'function') { alert(errorMsg); }",
" });",
" });",
" });",
" }",
" if (typeof document !== 'undefined') {",
" if (document.readyState === 'loading') {",
" document.addEventListener('DOMContentLoaded', pbInitFormControllers);",
" } else {",
" pbInitFormControllers();",
" }",
" }",
" console.log('Page Builder static export loaded');",
"})();",
].join("\n");
zip.file("main.js", mainJs);
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
+153 -7
View File
@@ -1,15 +1,158 @@
import { NextResponse } from "next/server";
import type { FormBlockProps } from "@/features/editor/state/editorStore";
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();
// 기본 필드(name/email/message)는 그대로 읽어둔다.
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
// 에디터에서 넘겨준 폼 설정(JSON) 읽기
const rawConfig = formData.get("__config");
let config: FormBlockProps | null = null;
if (typeof rawConfig === "string") {
@@ -24,15 +167,18 @@ export async function POST(req: Request) {
const successMessage = config?.successMessage;
const errorMessage = config?.errorMessage;
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
if (submitTarget === "internal") {
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
await persistFormSubmission(formData, config);
console.log("[forms/submit][internal]", { name, email, message });
return NextResponse.json({ ok: true, message: successMessage });
}
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
if (submitTarget === "webhook") {
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(
+22 -26
View File
@@ -1,40 +1,36 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient } from "@prisma/client";
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
let prisma: PrismaClient | null = null;
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
prisma = null;
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
prisma = null;
}
}
interface Params {
params: {
id: string;
};
}
export async function GET(request: Request) {
// Next.js 15 이후 params 가 Promise 로 전달되는 경고를 피하기 위해,
// 동적 세그먼트는 request.url 의 path 에서 직접 파싱한다.
let id: string | undefined;
export async function GET(request: Request, ctx: Params) {
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
let id: string | undefined = ctx?.params?.id;
if (!id) {
try {
const url = new URL(request.url);
const segments = url.pathname.split("/").filter(Boolean);
// [..., "api", "image", ":id"] 형태를 기대한다.
const last = segments[segments.length - 1];
if (last && last !== "image") {
id = last;
}
} catch {
// URL 파싱 실패 시에는 그대로 둔다.
try {
const url = new URL(request.url);
const segments = url.pathname.split("/").filter(Boolean);
// [..., "api", "image", ":id"] 형태를 기대한다.
const last = segments[segments.length - 1];
if (last && last !== "image") {
id = last;
}
} catch {
// URL 파싱 실패 시에는 그대로 둔다.
}
if (!id) {
+10 -8
View File
@@ -2,19 +2,21 @@ import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { randomUUID } from "crypto";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient } from "@prisma/client";
// 업로드된 이미지 파일을 저장할 디렉터리 (프로젝트 루트 기준)
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
let prisma: PrismaClient | null = null;
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
prisma = null;
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
try {
prisma = new PrismaClient();
} catch (error) {
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
prisma = null;
}
}
export async function POST(request: Request) {
+83 -7
View File
@@ -1,24 +1,100 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient } from "@prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
const prisma = new PrismaClient();
interface Params {
params: {
slug: string;
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(_request: Request, { params }: Params) {
const { slug } = params;
export async function GET(
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project) {
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
return NextResponse.json(project, { status: 200 });
}
export async function DELETE(
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 삭제하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
try {
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
await prisma.project.delete({
where: { slug },
});
return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 });
} catch (error: any) {
// Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다.
if (error?.code === "P2025") {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
return NextResponse.json(
{ message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
@@ -0,0 +1,92 @@
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 });
}
+111 -3
View File
@@ -1,9 +1,82 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient } from "@prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
import type { Block } from "@/features/editor/state/editorStore";
import { cachePublicProject } from "@/features/projects/publicCache";
const prisma = new PrismaClient();
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
try {
const projects = await prisma.project.findMany({
where: { userId: authUser.id },
orderBy: { createdAt: "desc" },
take: 50,
select: {
id: true,
title: true,
slug: true,
status: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(projects, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 저장하려면 로그인이 필요합니다." }, { status: 401 });
}
const body = await request.json();
const { title, slug, contentJson } = body;
@@ -12,14 +85,49 @@ export async function POST(request: Request) {
}
try {
const project = await prisma.project.create({
data: {
const existing = await prisma.project.findUnique({
where: { slug },
});
if (existing && existing.userId && existing.userId !== authUser.id) {
return NextResponse.json(
{ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." },
{ status: 409 },
);
}
const project = await prisma.project.upsert({
where: { slug },
update: {
title,
contentJson,
},
create: {
title,
slug,
contentJson,
userId: authUser.id,
},
});
// 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록
// 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다.
const rawContent = project.contentJson;
let contentBlocks: Block[] = [];
if (Array.isArray(rawContent)) {
contentBlocks = rawContent as unknown as Block[];
}
try {
cachePublicProject({
slug: project.slug,
title: project.title,
contentJson: contentBlocks,
});
} catch {
// 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다.
}
return NextResponse.json(project, { status: 201 });
} catch (error: any) {
return NextResponse.json(
+97
View File
@@ -0,0 +1,97 @@
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;
}
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
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;
}
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
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,
};
}
// GET /api/projects/submissions
// - 로그인한 사용자의 모든 프로젝트에 대한 폼 제출 내역을 최신순으로 최대 100건 반환한다.
// - FormSubmission.userId 가 현재 사용자와 일치하는 레코드만 포함한다.
// - payloadJson 과 sensitiveEnc 복호화 결과를 병합해 payload 로 반환한다.
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json(
{ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." },
{ status: 401 },
);
}
const submissions = await prisma.formSubmission.findMany({
where: { userId: authUser.id },
orderBy: { createdAt: "desc" },
take: 100,
include: {
project: {
select: {
title: true,
slug: true,
},
},
},
});
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,
projectTitle: s.project?.title ?? null,
payload,
};
}),
);
return NextResponse.json(items, { status: 200 });
}
+12 -4
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient } from "@prisma/client";
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
@@ -15,14 +15,22 @@ try {
}
interface Params {
params: {
params: Promise<{
id: string;
};
}>;
}
export async function GET(request: Request, ctx: Params) {
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
let id: string | undefined = ctx?.params?.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);
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { randomUUID } from "crypto";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClient } from "@prisma/client";
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
+361
View File
@@ -0,0 +1,361 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
// 대시보드 페이지 (/dashboard)
// - /api/dashboard/overview 에서 요약/프로젝트별 통계를 가져와 카드 형태로 렌더링한다.
// - 비로그인(401) 응답 시 로그인 페이지로 리다이렉트하고 에러 메시지를 표시한다.
interface SummaryStats {
totalProjects: number;
totalSubmissions: number;
todaySubmissions: number;
last7DaysSubmissions: number;
}
interface DailySubmissionItem {
// 일별 제출 수 그래프를 위한 집계 데이터
date: string; // YYYY-MM-DD
count: number;
}
interface ProjectStatsItem {
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: string | null;
}
type PageStatus = "idle" | "loading" | "error";
export default function DashboardPage() {
const router = useRouter();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const [summary, setSummary] = useState<SummaryStats | null>(null);
const [projects, setProjects] = useState<ProjectStatsItem[]>([]);
const [dailySubmissions, setDailySubmissions] = useState<DailySubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// 마운트 시 대시보드 개요 데이터를 불러온다.
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch("/api/dashboard/overview");
if (!res.ok) {
if (cancelled) return;
if (res.status === 401) {
setStatus("error");
setErrorMessage(t.errorUnauthorized);
router.push("/login");
return;
}
setStatus("error");
setErrorMessage(t.errorGeneric);
return;
}
const data = (await res.json()) as {
summaryStats: SummaryStats;
projectStats: ProjectStatsItem[];
dailySubmissions?: DailySubmissionItem[];
};
if (cancelled) return;
setSummary(data.summaryStats ?? null);
setProjects(Array.isArray(data.projectStats) ? data.projectStats : []);
setDailySubmissions(
Array.isArray(data.dailySubmissions) ? data.dailySubmissions : [],
);
setStatus("idle");
setErrorMessage(null);
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage(t.errorGeneric);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [router]);
const safeSummary: SummaryStats =
summary ?? {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
return;
}
router.push("/login");
} catch {
}
};
const handleToggleTheme = () => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
};
return (
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t.title}</h1>
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400">{t.description}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<Link
href="/dashboard"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
<button
type="button"
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen(false);
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex-1 px-6 py-4 space-y-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && errorMessage && (
<p className="text-xs text-red-500 dark:text-red-300">{errorMessage}</p>
)}
{status === "loading" && (
<p className="text-xs text-slate-400">{t.loadingText}</p>
)}
{/* 상단 요약 위젯 영역 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div
data-testid="dashboard-summary-total-projects"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryTotalProjectsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalProjects}</span>
</div>
<div
data-testid="dashboard-summary-total-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryTotalSubmissionsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalSubmissions}</span>
</div>
<div
data-testid="dashboard-summary-today-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryTodaySubmissionsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.todaySubmissions}</span>
</div>
<div
data-testid="dashboard-summary-last7days-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
{t.summaryLast7DaysSubmissionsLabel}
</span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.last7DaysSubmissions}</span>
</div>
</div>
{/* 일별 제출 수 간단 그래프 영역 */}
<div
data-testid="dashboard-daily-chart"
className="mt-2 rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{t.chartTitle}</h2>
<span className="text-[10px] text-slate-500 dark:text-slate-500">{t.chartSubtitle}</span>
</div>
{dailySubmissions.length === 0 ? (
<p className="text-[11px] text-slate-500 dark:text-slate-400">{t.chartEmptyLabel}</p>
) : (
<div className="flex items-end gap-2 text-[10px]">
{dailySubmissions.map((item) => (
<div
key={item.date}
data-testid="dashboard-daily-chart-bar"
className="flex flex-col items-center gap-1"
>
<div
className="w-6 rounded-t bg-emerald-500/80"
style={{ height: `${Math.max(8, item.count * 12)}px` }}
/>
<span className="text-slate-700 font-semibold dark:text-slate-300">{item.count}</span>
<span className="text-slate-500 dark:text-slate-500">{item.date}</span>
</div>
))}
</div>
)}
</div>
{/* 프로젝트 카드 리스트 영역 */}
<div className="mt-2">
<h2 className="text-base font-semibold mb-2 text-slate-800 dark:text-slate-100">
{t.sectionProjectsTitle}
</h2>
{projects.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400">{t.sectionProjectsEmpty}</p>
)}
{projects.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const lastSubmitted = project.lastSubmissionAt
? new Date(project.lastSubmissionAt).toLocaleString()
: t.projectLatestSubmissionEmpty;
return (
<article
key={project.projectId}
data-testid="dashboard-project-card"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<div className="flex items-baseline justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">{project.title}</h3>
<p className="text-[11px] text-slate-500 font-mono dark:text-slate-400">{project.slug}</p>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-transparent dark:text-slate-300">
{project.status}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-700 dark:text-slate-300">
<span>
{t.projectTotalSubmissionsPrefix}
<span className="font-semibold">{project.totalSubmissions}</span>
{t.projectTotalSubmissionsSuffix}
</span>
<span className="text-slate-500 dark:text-slate-400">
{t.projectLatestSubmissionPrefix} {lastSubmitted}
</span>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px]">
<Link
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
className="text-emerald-700 hover:text-emerald-900 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
>
{t.projectViewSubmissionsLink}
</Link>
<Link
href={`/p/${encodeURIComponent(project.slug)}`}
className="text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
>
{t.projectOpenPublicPageLink}
</Link>
</div>
</article>
);
})}
</div>
)}
</div>
</section>
</main>
);
}
+22 -10
View File
@@ -11,6 +11,8 @@ 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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorCanvasMessages } from "@/features/i18n/messages/editorCanvas";
interface EditorCanvasProps {
blocks: Block[];
@@ -42,6 +44,9 @@ export function EditorCanvas(props: EditorCanvasProps) {
projectConfig,
} = props;
const locale = useAppLocale();
const m = getEditorCanvasMessages(locale);
const canvasOuterStyle: CSSProperties = {};
const canvasInnerStyle: CSSProperties = {};
const preset = projectConfig.canvasPreset ?? "full";
@@ -70,7 +75,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
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"
className="flex-1 p-4 flex flex-col gap-2 text-sm border-r border-slate-800 overflow-auto pb-scroll"
data-testid="editor-canvas"
style={canvasOuterStyle}
onClick={(event) => {
@@ -86,7 +91,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" .
{m.emptyStateHint}
</div>
) : (
<DndContext
@@ -118,11 +123,14 @@ interface DragPreviewProps {
block: Block | null;
}
function DragPreview({ block }: DragPreviewProps) {
export function EditorCanvasDragPreview({ block }: DragPreviewProps) {
if (!block) return null;
const locale = useAppLocale();
const m = getEditorCanvasMessages(locale);
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="pointer-events-none rounded border border-sky-500 bg-white px-3 py-2 text-xs text-slate-900 shadow-lg opacity-90 min-w-[160px] max-w-[260px] dark:bg-slate-900 dark:text-slate-100">
<div className="text-[10px] uppercase tracking-wide text-sky-300 mb-1">
{block.type === "text"
? "Text"
@@ -138,17 +146,21 @@ function DragPreview({ block }: DragPreviewProps) {
</div>
<div className="truncate">
{block.type === "text"
? (block.props as TextBlockProps).text || "텍스트 블록"
? (block.props as TextBlockProps).text || m.previewFallbackTextBlock
: block.type === "button"
? (block.props as ButtonBlockProps).label || "버튼 블록"
? (block.props as ButtonBlockProps).label || m.previewFallbackButtonBlock
: block.type === "image"
? (block.props as ImageBlockProps).alt || "이미지 블록"
? (block.props as ImageBlockProps).alt || m.previewFallbackImageBlock
: block.type === "list"
? "리스트 블록"
? m.previewFallbackListBlock
: block.type === "divider"
? "구분선 블록"
: "섹션 블록"}
? m.previewFallbackDividerBlock
: m.previewFallbackSectionBlock}
</div>
</div>
);
}
function DragPreview({ block }: DragPreviewProps) {
return <EditorCanvasDragPreview block={block} />;
}
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormCheckboxPanelMessages } from "@/features/i18n/messages/editorFormCheckboxPanel";
interface FormCheckboxPropertiesPanelProps {
block: Block;
@@ -11,18 +13,21 @@ interface FormCheckboxPropertiesPanelProps {
}
export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBlock }: FormCheckboxPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormCheckboxPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const checkboxProps = block.props as FormCheckboxBlockProps;
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.groupLabelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -30,15 +35,87 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.groupTitleTypeOptionText}</option>
<option value="image">{m.groupTitleTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={groupLabelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelDisplay: e.target.value,
} as any)
}
>
<option value="visible">{m.groupTitleDisplayOptionVisible}</option>
<option value="hidden">{m.groupTitleDisplayOptionHidden}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.optionLayoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.optionLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8}
min={0}
max={80}
step={2}
presets={[
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
labelGapPx: v,
} as any)
}
/>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.groupLabel ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -58,9 +135,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageSourceLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={source}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -68,16 +145,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.groupTitleImageSourceOptionUrl}</option>
<option value="upload">{m.groupTitleImageSourceOptionUpload}</option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<span className="text-slate-400">{m.groupTitleImageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.groupLabelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -90,12 +167,12 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageUploadLabel}</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="그룹 타이틀 이미지 파일 업로드"
aria-label={m.groupTitleImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -135,9 +212,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -149,16 +226,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400"></span>
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</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"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={() => {
const next = Array.isArray((checkboxProps as any).options)
? [...(checkboxProps as any).options]
: [];
next.push({
label: "새 옵션",
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
@@ -166,14 +243,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any);
}}
>
{m.addOptionButtonLabel}
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={(() => {
if (checkboxProps.optionImageSource) return checkboxProps.optionImageSource;
const first = (((checkboxProps as any).options ?? []) as any[])[0];
@@ -186,17 +263,17 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.optionImageSourceOptionUrl}</option>
<option value="upload">{m.optionImageSourceOptionUpload}</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">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] 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="라벨"
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionLabelPlaceholder}
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
@@ -210,8 +287,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<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)"
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionValuePlaceholder}
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
@@ -226,7 +303,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
/>
<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"
className="h-7 w-7 rounded border border-slate-300 bg-white text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
onClick={() => {
const next = (((checkboxProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
updateBlock(selectedBlockId, {
@@ -247,8 +324,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
<>
{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 (선택)"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionImageUrlPlaceholder}
value={opt.labelImageUrl ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
@@ -264,12 +341,12 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageUploadLabel}</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="옵션 이미지 파일 업로드"
aria-label={m.optionImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -320,25 +397,15 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</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>
<span className="text-slate-500">{m.requiredNoticeText}</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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* px 기반 체크박스 타이포 입력값을 노출하여 em 스케일 변환을 위한 근거를 남긴다 */}
<NumericPropertyControl
label="체크박스 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = checkboxProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -355,16 +422,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
fontSizeCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 줄간격 px 입력값을 노출해 preview em 변환과 TDD 케이스를 연동한다 */}
<NumericPropertyControl
label="체크박스 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = checkboxProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -375,20 +442,20 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
lineHeightCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 자간 px 입력을 통한 폼 체크박스 미세 조정값을 노출 */}
<NumericPropertyControl
label="체크박스 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = checkboxProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -399,20 +466,20 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
letterSpacingCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<span>{m.widthModeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={checkboxProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -420,44 +487,44 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(checkboxProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
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 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
} as any)
}
/>
)}
<NumericPropertyControl
label="체크박스 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
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 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 10 },
{ id: "md", label: "Normal", value: 12 },
{ id: "lg", label: "Wide", value: 16 },
{ id: "xl", label: "Extra wide", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -466,18 +533,18 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}
/>
<NumericPropertyControl
label="체크박스 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
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 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 8 },
{ id: "md", label: "Normal", value: 10 },
{ id: "lg", label: "Wide", value: 12 },
{ id: "xl", label: "Extra wide", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -486,17 +553,17 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}
/>
<NumericPropertyControl
label="체크박스 옵션 간격 (px)"
unitLabel="(px)"
label={m.optionGapLabel}
unitLabel={m.optionGapUnitLabel}
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 },
{ id: "tight", label: "Tight", value: 2 },
{ id: "normal", label: "Normal", value: 4 },
{ id: "relaxed", label: "Relaxed", value: 8 },
{ id: "extra", label: "Extra", value: 12 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -505,13 +572,13 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}
/>
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="체크박스 텍스트 색상 피커"
ariaLabelHexInput="체크박스 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== ""
? checkboxProps.textColorCustom
: "#f9fafb"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -526,13 +593,13 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="체크박스 배경 색상 피커"
ariaLabelHexInput="체크박스 배경 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== ""
? checkboxProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -547,13 +614,13 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<ColorPickerField
label="테두리 색상"
ariaLabelColorInput="체크박스 테두리 색상 피커"
ariaLabelHexInput="체크박스 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== ""
? checkboxProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -568,7 +635,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = checkboxProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -577,11 +644,11 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
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 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
+245 -48
View File
@@ -1,6 +1,11 @@
"use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { computeFormControllerPublicTokens } from "@/features/editor/utils/formHelpers";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormControllerPanelMessages } from "@/features/i18n/messages/editorFormControllerPanel";
interface FormControllerPanelProps {
block: Block; // type === "form"
@@ -12,20 +17,74 @@ interface FormControllerPanelProps {
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const [showSheetsGuide, setShowSheetsGuide] = useState(false);
const formProps = block.props as FormBlockProps;
const locale = useAppLocale();
const m = getEditorFormControllerPanelMessages(locale);
const sheetsScriptExample = (() => {
const tokens = computeFormControllerPublicTokens(block, blocks);
const fieldNames = tokens.fields.map((f) => f.name).filter((name) => !!name);
if (fieldNames.length === 0) {
return (
"function doPost(e) {\n" +
' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");\n' +
" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }\n" +
" var params = e.parameter; // x-www-form-urlencoded\n" +
" var row = [\n" +
' params.name || "",\n' +
' params.email || "",\n' +
' params.message || "",\n' +
" new Date(),\n" +
" ];\n" +
" sheet.appendRow(row);\n" +
" return ContentService\n" +
" .createTextOutput(JSON.stringify({ ok: true }))\n" +
" .setMimeType(ContentService.MimeType.JSON);\n" +
"}"
);
}
const headerNames = [...fieldNames, "createdAt"];
const rowLines = fieldNames.map((name) => ` params.${name} || "",`);
rowLines.push(" new Date(),");
const lines: string[] = [];
lines.push("function doPost(e) {");
lines.push(' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");');
lines.push(" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }");
lines.push(" var params = e.parameter; // x-www-form-urlencoded");
lines.push(" // 시트의 1행 헤더는 다음과 같이 맞춰 주세요:");
lines.push(` // ${headerNames.join(", ")}`);
lines.push(" var row = [");
for (const line of rowLines) {
lines.push(line);
}
lines.push(" ];");
lines.push(" sheet.appendRow(row);");
lines.push(" return ContentService");
lines.push(" .createTextOutput(JSON.stringify({ ok: true }))");
lines.push(" .setMimeType(ContentService.MimeType.JSON);");
lines.push("}");
return lines.join("\n");
})();
return (
<div className="space-y-4 text-xs">
<p className="text-slate-400">
<code className="font-mono text-[11px]">/api/forms/submit</code>
, Webhook .
{m.introTextPrefix}
<code className="font-mono text-[11px]">/api/forms/submit</code>
{m.introTextSuffix}
</p>
<div className="space-y-2">
<div className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.submitTargetLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.submitTarget ?? "internal"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -33,18 +92,18 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value="internal"> (Webhook )</option>
<option value="webhook"> Webhook / Google Sheets </option>
<option value="internal">{m.submitTargetOptionInternal}</option>
<option value="webhook">{m.submitTargetOptionWebhook}</option>
</select>
</div>
</label>
{formProps.submitTarget === "webhook" && (
<div className="space-y-2">
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-slate-400">Webhook / Google Sheets URL</span>
<span className="text-slate-500 dark:text-slate-400">{m.webhookUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.webhookUrlPlaceholder}
value={formProps.destinationUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -54,9 +113,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.payloadFormatLabel}</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"
className="w-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.payloadFormat ?? "form"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -64,14 +123,14 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value="form"> (x-www-form-urlencoded)</option>
<option value="json">JSON (application/json)</option>
<option value="form">{m.payloadFormatOptionForm}</option>
<option value="json">{m.payloadFormatOptionJson}</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-400">HTTP </span>
<span className="text-slate-500 dark:text-slate-400">{m.httpMethodLabel}</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"
className="w-28 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.method ?? "POST"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -84,10 +143,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400">Authorization ()</span>
<span className="text-slate-500 dark:text-slate-400">{m.authTokenLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.authTokenPlaceholder}
value={formProps.headers?.Authorization ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -100,10 +159,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> (key=value , )</span>
<span className="text-slate-500 dark:text-slate-400">{m.extraParamsLabel}</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"}
className="w-full h-16 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.extraParamsPlaceholder}
value={Object.entries(formProps.extraParams ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n")}
@@ -125,17 +184,88 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
}}
/>
</label>
<div className="flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={() => setShowSheetsGuide(true)}
>
{m.sheetsGuideButtonLabel}
</button>
</div>
</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>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.layoutSectionTitle}</h3>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>{m.formWidthModeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={formProps.formWidthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
formWidthMode: e.target.value as FormBlockProps["formWidthMode"],
} as any)
}
>
<option value="auto">{m.formWidthModeOptionAuto}</option>
<option value="full">{m.formWidthModeOptionFull}</option>
<option value="fixed">{m.formWidthModeOptionFixed}</option>
</select>
</label>
{(formProps.formWidthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label={m.formFixedWidthLabel}
unitLabel="(px)"
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
min={160}
max={1200}
step={10}
presets={[
{ id: "sm", label: m.formFixedWidthPresetNarrow, value: 320 },
{ id: "md", label: m.formFixedWidthPresetNormal, value: 480 },
{ id: "lg", label: m.formFixedWidthPresetWide, value: 640 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
formWidthPx: v,
} as any)
}
/>
)}
<NumericPropertyControl
label={m.marginYLabel}
unitLabel="(px)"
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
min={0}
max={160}
step={4}
presets={[
{ id: "none", label: m.marginYPresetNone, value: 0 },
{ id: "compact", label: m.marginYPresetCompact, value: 16 },
{ id: "normal", label: m.marginYPresetNormal, value: 24 },
{ id: "spacious", label: m.marginYPresetSpacious, 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-800 dark:text-slate-200">{m.messagesSectionTitle}</h3>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>{m.successMessageLabel}</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="예: 성공적으로 전송되었습니다."
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.successMessagePlaceholder}
value={formProps.successMessage ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -144,11 +274,11 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
}
/>
</label>
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>{m.errorMessageLabel}</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="예: 전송 중 오류가 발생했습니다."
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.errorMessagePlaceholder}
value={formProps.errorMessage ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -160,10 +290,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
</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>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.controllerSectionTitle}</h3>
<fieldset className="space-y-2" aria-label="폼 필드 매핑">
<legend className="text-[11px] text-slate-400 mb-1"> </legend>
<fieldset className="space-y-2" aria-label={m.fieldMappingAriaLabel}>
<legend className="text-[11px] text-slate-400 mb-1">{m.fieldMappingLegend}</legend>
{blocks
.filter((b) =>
b.type === "formInput" ||
@@ -173,21 +303,28 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
)
.map((fieldBlock) => {
const fieldId = fieldBlock.id;
const fieldLabel = (fieldBlock.props as any).label ??
(fieldBlock.props as any).groupLabel ??
(fieldBlock.props as any).formFieldName ??
fieldId;
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);
const required = (formProps.requiredFieldIds ?? []).includes(fieldId);
return (
<label
key={fieldId}
className="flex items-center gap-2 text-[11px] text-slate-200"
className="flex items-center gap-2 text-[11px] text-slate-800 dark:text-slate-200"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
checked={checked}
onChange={(e) => {
const current = formProps.fieldIds ?? [];
@@ -200,17 +337,35 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any);
}}
/>
<span>{fieldLabel}</span>
<span className="flex-1 truncate">{displayLabel}</span>
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
checked={required}
onChange={(e) => {
const current = formProps.requiredFieldIds ?? [];
const next = e.target.checked
? Array.from(new Set([...current, fieldId]))
: current.filter((id) => id !== fieldId);
updateBlock(selectedBlockId, {
requiredFieldIds: next,
} as any);
}}
/>
<span>{m.requiredLabel}</span>
</label>
</label>
);
})}
</fieldset>
<div className="space-y-1">
<span className="text-[11px] text-slate-400">Submit </span>
<span className="text-[11px] text-slate-400">{m.submitButtonLabel}</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 버튼"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.submitButtonAriaLabel}
value={formProps.submitButtonId ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -218,20 +373,62 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value=""> </option>
<option value="">{m.submitButtonNoneOptionLabel}</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}>
{btnProps.label || buttonBlock.id}
{displayLabel}
</option>
);
})}
</select>
</div>
</div>
{showSheetsGuide && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-xl rounded border border-slate-200 bg-white px-4 py-3 text-xs text-slate-900 shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[11px] font-semibold">{m.sheetsGuideHeading}</h4>
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-100"
onClick={() => setShowSheetsGuide(false)}
aria-label={m.sheetsGuideCloseAriaLabel}
>
×
</button>
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
{m.sheetsGuideIntro}
</p>
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
<li>{m.sheetsGuideStep1}</li>
<li>{m.sheetsGuideStep2}</li>
<li>{m.sheetsGuideStep3}</li>
</ol>
<div className="mt-2 space-y-1">
<span className="text-[11px] text-slate-500 dark:text-slate-400">{m.sheetsGuideExampleCodeLabel}</span>
<textarea
className="w-full h-40 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
readOnly
value={sheetsScriptExample}
/>
</div>
</div>
</div>
)}
</div>
);
}
+159 -148
View File
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormInputPanelMessages } from "@/features/i18n/messages/editorFormInputPanel";
interface FormInputPropertiesPanelProps {
block: Block;
@@ -11,17 +13,20 @@ interface FormInputPropertiesPanelProps {
}
export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }: FormInputPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormInputPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId || block.type !== "formInput") return null;
const inputProps = block.props as FormInputBlockProps;
const labelDisplay = inputProps.labelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.labelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -29,14 +34,14 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.labelTypeOptionText}</option>
<option value="image">{m.labelTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.label ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -45,12 +50,66 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.labelDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={labelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelDisplay: e.target.value,
} as any)
}
>
<option value="visible">{m.labelDisplayOptionVisible}</option>
<option value="hidden">{m.labelDisplayOptionHidden}</option>
<option value="floating">{m.labelDisplayOptionFloating}</option>
</select>
</label>
{labelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
{labelDisplay === "visible" && (inputProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label={m.labelGapLabel}
unitLabel={m.labelGapUnitLabel}
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
min={0}
max={80}
step={2}
presets={[
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
labelGapPx: v,
} as any)
}
/>
)}
{inputProps.labelMode === "image" && (
<>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<span className="text-slate-500 dark:text-slate-400">{m.labelImageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.labelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -60,9 +119,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelImageAltLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.labelImageAlt ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -74,9 +133,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
</>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -86,10 +145,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.fieldTypeLabel}</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="필드 타입"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.fieldTypeAria}
value={inputProps.inputType ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -97,16 +156,16 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="text"></option>
<option value="email"></option>
<option value="textarea"> </option>
<option value="text">{m.fieldTypeOptionText}</option>
<option value="email">{m.fieldTypeOptionEmail}</option>
<option value="textarea">{m.fieldTypeOptionTextarea}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400">Placeholder</span>
<span className="text-slate-500 dark:text-slate-400">{m.placeholderLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.placeholderAria}
value={inputProps.placeholder ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -116,23 +175,13 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</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>
<span className="text-slate-500 dark:text-slate-400">{m.requiredNoticeText}</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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label="필드 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = inputProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -157,8 +206,8 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
{/* 줄간격 px 입력을 노출하여 preview em 변환의 근거를 제공 */}
<NumericPropertyControl
label="필드 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = inputProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -169,9 +218,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -181,8 +230,8 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
{/* 자간 px 입력으로 폼 입력 타이포를 세밀하게 조정 */}
<NumericPropertyControl
label="필드 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = inputProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -193,9 +242,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -203,10 +252,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.textAlignLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={inputProps.align ?? "left"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -214,51 +263,16 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.textAlignOptionLeft}</option>
<option value="center">{m.textAlignOptionCenter}</option>
<option value="right">{m.textAlignOptionRight}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span></span>
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.widthModeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.widthModeAria}
value={inputProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -266,44 +280,42 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</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)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
value={inputProps.widthPx ?? 240}
min={80}
max={800}
step={10}
presets={[
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
/>
<NumericPropertyControl
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
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 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 10 },
{ id: "md", label: "Normal", value: 12 },
{ id: "lg", label: "Wide", value: 16 },
{ id: "xl", label: "Extra wide", value: 20 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -312,18 +324,18 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<NumericPropertyControl
label="필드 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
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 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 8 },
{ id: "md", label: "Normal", value: 10 },
{ id: "lg", label: "Wide", value: 12 },
{ id: "xl", label: "Extra wide", value: 16 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -332,13 +344,13 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<ColorPickerField
label="필드 텍스트 색상"
ariaLabelColorInput="필드 텍스트 색상 피커"
ariaLabelHexInput="필드 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
inputProps.textColorCustom && inputProps.textColorCustom.trim() !== ""
? inputProps.textColorCustom
: "#f9fafb"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -353,13 +365,13 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<ColorPickerField
label="필드 채움 색상"
ariaLabelColorInput="필드 채움 색상 피커"
ariaLabelHexInput="필드 채움 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== ""
? inputProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -374,13 +386,13 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<ColorPickerField
label="필드 테두리 색상"
ariaLabelColorInput="필드 테두리 색상 피커"
ariaLabelHexInput="필드 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== ""
? inputProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -395,7 +407,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = inputProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -404,15 +416,14 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
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 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
const next = v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
} as any);
+185 -109
View File
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormRadioPanelMessages } from "@/features/i18n/messages/editorFormRadioPanel";
interface FormRadioPropertiesPanelProps {
block: Block;
@@ -11,18 +13,21 @@ interface FormRadioPropertiesPanelProps {
}
export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }: FormRadioPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormRadioPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const radioProps = block.props as FormRadioBlockProps;
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -30,15 +35,87 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.groupTitleTypeOptionText}</option>
<option value="image">{m.groupTitleTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={groupLabelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelDisplay: e.target.value,
} as any)
}
>
<option value="visible">{m.groupTitleDisplayOptionVisible}</option>
<option value="hidden">{m.groupTitleDisplayOptionHidden}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.optionLayoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.optionLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.optionLayoutOptionStacked}</option>
<option value="inline">{m.optionLayoutOptionInline}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (radioProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8}
min={0}
max={80}
step={2}
presets={[
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
labelGapPx: v,
} as any)
}
/>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabel ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -58,9 +135,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageSourceLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={source}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -68,16 +145,16 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.groupTitleImageSourceOptionUrl}</option>
<option value="upload">{m.groupTitleImageSourceOptionUpload}</option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<span className="text-slate-400">{m.groupTitleImageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -90,12 +167,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageUploadLabel}</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="그룹 타이틀 이미지 파일 업로드"
aria-label={m.groupTitleImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -135,9 +212,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -149,16 +226,16 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400"></span>
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</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"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
onClick={() => {
const next = Array.isArray((radioProps as any).options)
? [...(radioProps as any).options]
: [];
next.push({
label: "새 옵션",
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
@@ -166,14 +243,14 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
>
{m.addOptionButtonLabel}
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={(() => {
if (radioProps.optionImageSource) return radioProps.optionImageSource;
const first = (((radioProps as any).options ?? []) as any[])[0];
@@ -186,17 +263,17 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.optionImageSourceOptionUrl}</option>
<option value="upload">{m.optionImageSourceOptionUpload}</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">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] 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="라벨"
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionLabelPlaceholder}
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
@@ -210,8 +287,8 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
<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)"
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionValuePlaceholder}
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
@@ -226,7 +303,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
<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"
className="h-7 w-7 rounded border border-slate-300 bg-white text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
onClick={() => {
const next = (((radioProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
updateBlock(selectedBlockId, {
@@ -247,8 +324,8 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
<>
{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 (선택)"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionImageUrlPlaceholder}
value={opt.labelImageUrl ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
@@ -264,12 +341,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageUploadLabel}</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="옵션 이미지 파일 업로드"
aria-label={m.optionImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -320,25 +397,15 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</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>
<span className="text-slate-500">{m.requiredNoticeText}</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 스케일 변환 로직과 연동한다 */}
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label="라디오 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = radioProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -361,10 +428,10 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
{/* 줄간격 px 입력을 노출하여 preview 에서 em 변환 근거로 사용 */}
<NumericPropertyControl
label="라디오 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = radioProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -375,9 +442,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -385,10 +452,10 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
{/* 자간 px 입력 컨트롤로 프리뷰 적용 근거 제공 */}
<NumericPropertyControl
label="라디오 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = radioProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -399,9 +466,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -409,10 +476,11 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<span>Field width</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -420,23 +488,24 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">Auto</option>
<option value="full">Full width</option>
<option value="fixed">Fixed width</option>
</select>
</label>
{(radioProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
label="Field fixed width"
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 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -445,19 +514,20 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
}}
/>
)}
<NumericPropertyControl
label="라디오 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
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 },
{ id: "xs", label: "Extra small", value: 6 },
{ id: "sm", label: "Small", value: 10 },
{ id: "md", label: "Medium", value: 12 },
{ id: "lg", label: "Large", value: 16 },
{ id: "xl", label: "Extra large", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -465,19 +535,20 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
/>
<NumericPropertyControl
label="라디오 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
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 },
{ id: "xs", label: "Extra small", value: 6 },
{ id: "sm", label: "Small", value: 8 },
{ id: "md", label: "Medium", value: 10 },
{ id: "lg", label: "Large", value: 12 },
{ id: "xl", label: "Extra large", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -485,18 +556,19 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
/>
<NumericPropertyControl
label="라디오 옵션 간격 (px)"
unitLabel="(px)"
label={m.optionGapLabel}
unitLabel={m.optionGapUnitLabel}
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 },
{ id: "tight", label: "Tight", value: 2 },
{ id: "normal", label: "Normal", value: 4 },
{ id: "relaxed", label: "Relaxed", value: 8 },
{ id: "extra", label: "Extra", value: 12 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -504,14 +576,15 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
/>
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="라디오 텍스트 색상 피커"
ariaLabelHexInput="라디오 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
radioProps.textColorCustom && radioProps.textColorCustom.trim() !== ""
? radioProps.textColorCustom
: "#f9fafb"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -525,14 +598,15 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="라디오 배경 색상 피커"
ariaLabelHexInput="라디오 배경 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== ""
? radioProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -546,14 +620,15 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<ColorPickerField
label="테두리 색상"
ariaLabelColorInput="라디오 테두리 색상 피커"
ariaLabelHexInput="라디오 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== ""
? radioProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -567,8 +642,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = radioProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -577,11 +653,11 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
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 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormSelectPanelMessages } from "@/features/i18n/messages/editorFormSelectPanel";
interface FormSelectPropertiesPanelProps {
block: Block;
@@ -11,18 +13,21 @@ interface FormSelectPropertiesPanelProps {
}
export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock }: FormSelectPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormSelectPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const selectProps = block.props as FormSelectBlockProps;
const labelDisplay = selectProps.labelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.labelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -30,15 +35,71 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.labelTypeOptionText}</option>
<option value="image">{m.labelTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.labelDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={labelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelDisplay: e.target.value,
} as any)
}
>
<option value="visible">{m.labelDisplayOptionVisible}</option>
<option value="hidden">{m.labelDisplayOptionHidden}</option>
</select>
</label>
{labelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
{labelDisplay === "visible" && (selectProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof selectProps.labelGapPx === "number" ? selectProps.labelGapPx : 8}
min={0}
max={80}
step={2}
presets={[
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
labelGapPx: v,
} as any)
}
/>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.label ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -49,9 +110,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -63,16 +124,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400"></span>
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</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"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={() => {
const next = Array.isArray((selectProps as any).options)
? [...(selectProps as any).options]
: [];
next.push({
label: "새 옵션",
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
@@ -80,16 +141,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any);
}}
>
{m.addOptionButtonLabel}
</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">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] 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="라벨"
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionLabelPlaceholder}
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((selectProps as any).options ?? []) as any[])];
@@ -103,8 +164,8 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<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)"
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionValuePlaceholder}
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((selectProps as any).options ?? []) as any[])];
@@ -119,7 +180,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
/>
<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"
className="h-7 w-7 rounded border border-slate-300 bg-white text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
onClick={() => {
const next = (((selectProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
updateBlock(selectedBlockId, {
@@ -136,23 +197,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
</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>
<span className="text-slate-500">{m.requiredNoticeText}</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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label="셀렉트 텍스트 크기 (px)"
unitLabel="(px)"
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = selectProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -169,16 +220,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
fontSizeCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 줄간격 px 입력을 받아 preview 렌더러의 em 변환 근거로 사용한다 */}
<NumericPropertyControl
label="셀렉트 줄간격 (px)"
unitLabel="(px)"
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = selectProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -189,20 +240,20 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
lineHeightCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
{/* 자간 px 입력을 제공하여 TDD 시나리오와 동기화한다 */}
<NumericPropertyControl
label="셀렉트 자간 (px)"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = selectProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -213,20 +264,20 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
letterSpacingCustom: `${v}px`,
} as any);
}}
} as any)
}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.widthModeLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={selectProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -234,44 +285,44 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">Auto</option>
<option value="full">Full</option>
<option value="fixed">Fixed</option>
</select>
</label>
{(selectProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
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 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
} as any)
}
/>
)}
<NumericPropertyControl
label="셀렉트 가로 패딩 (px)"
unitLabel="(px)"
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
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 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 10 },
{ id: "md", label: "Normal", value: 12 },
{ id: "lg", label: "Wide", value: 16 },
{ id: "xl", label: "Extra wide", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -280,18 +331,18 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}
/>
<NumericPropertyControl
label="셀렉트 세로 패딩 (px)"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
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 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 8 },
{ id: "md", label: "Normal", value: 10 },
{ id: "lg", label: "Wide", value: 12 },
{ id: "xl", label: "Extra wide", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -300,13 +351,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}
/>
<ColorPickerField
label="필드 텍스트 색상"
ariaLabelColorInput="셀렉트 텍스트 색상 피커"
ariaLabelHexInput="셀렉트 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
selectProps.textColorCustom && selectProps.textColorCustom.trim() !== ""
? selectProps.textColorCustom
: "#f9fafb"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -321,13 +372,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<ColorPickerField
label="필드 채움 색상"
ariaLabelColorInput="셀렉트 채움 색상 피커"
ariaLabelHexInput="셀렉트 채움 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== ""
? selectProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -342,13 +393,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<ColorPickerField
label="필드 테두리 색상"
ariaLabelColorInput="셀렉트 테두리 색상 피커"
ariaLabelHexInput="셀렉트 테두리 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== ""
? selectProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -363,7 +414,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = selectProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
@@ -372,11 +423,11 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
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 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, value: 8 },
]}
onChangeValue={(v) => {
const next =
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import "../../styles/editor.css";
export default function EditorLayout({ children }: { children: ReactNode }) {
// 에디터 전용 전역 스타일(editor.css)을 로드하기 위한 중첩 레이아웃.
// 실제 마크업 구조는 page.tsx 에서 정의하며, 여기서는 children 을 그대로 반환한다.
return children;
}
+778 -245
View File
File diff suppressed because it is too large Load Diff
+507 -383
View File
@@ -1,10 +1,30 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorSidebarMessages } from "@/features/i18n/messages/editorSidebar";
import {
FilePlus2,
ListChecks,
Type,
MousePointerClick,
Image as ImageIcon,
Video,
Minus,
List,
LayoutTemplate,
Pencil,
FileText,
ListFilter,
CircleDot,
SquareCheck,
} from "lucide-react";
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
export function BlocksSidebar() {
const locale = useAppLocale();
const m = getEditorSidebarMessages(locale);
const addTextBlock = useEditorStore((state) => state.addTextBlock);
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
const addImageBlock = useEditorStore((state) => state.addImageBlock);
@@ -30,416 +50,520 @@ export function BlocksSidebar() {
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
// 버튼 핸들러를 useCallback으로 래핑해 불필요한 재생성을 줄인다.
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
const handleAddText = useCallback(() => addTextBlock(locale), [addTextBlock, locale]);
const handleAddButton = useCallback(() => addButtonBlock(locale), [addButtonBlock, locale]);
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
const handleAddList = useCallback(() => addListBlock(locale), [addListBlock, locale]);
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 handleAddFormBlock = useCallback(() => addFormBlock(locale), [addFormBlock, locale]);
const handleAddFormInput = useCallback(() => addFormInputBlock(locale), [addFormInputBlock, locale]);
const handleAddFormSelect = useCallback(() => addFormSelectBlock(locale), [addFormSelectBlock, locale]);
const handleAddFormRadio = useCallback(() => addFormRadioBlock(locale), [addFormRadioBlock, locale]);
const handleAddFormCheckbox = useCallback(
() => addFormCheckboxBlock(locale),
[addFormCheckboxBlock, locale],
);
const handleAddHeroTemplate = useCallback(
() => addHeroTemplateSection(),
[addHeroTemplateSection],
() => addHeroTemplateSection(locale),
[addHeroTemplateSection, locale],
);
const handleAddFeaturesTemplate = useCallback(
() => addFeaturesTemplateSection(),
[addFeaturesTemplateSection],
() => addFeaturesTemplateSection(locale),
[addFeaturesTemplateSection, locale],
);
const handleAddCtaTemplate = useCallback(
() => addCtaTemplateSection(locale),
[addCtaTemplateSection, locale],
);
const handleAddFaqTemplate = useCallback(
() => addFaqTemplateSection(locale),
[addFaqTemplateSection, locale],
);
const handleAddCtaTemplate = useCallback(() => addCtaTemplateSection(), [addCtaTemplateSection]);
const handleAddFaqTemplate = useCallback(() => addFaqTemplateSection(), [addFaqTemplateSection]);
const handleAddPricingTemplate = useCallback(
() => addPricingTemplateSection(),
[addPricingTemplateSection],
() => addPricingTemplateSection(locale),
[addPricingTemplateSection, locale],
);
const handleAddTestimonialsTemplate = useCallback(
() => addTestimonialsTemplateSection(),
[addTestimonialsTemplateSection],
() => addTestimonialsTemplateSection(locale),
[addTestimonialsTemplateSection, locale],
);
const handleAddBlogTemplate = useCallback(
() => addBlogTemplateSection(locale),
[addBlogTemplateSection, locale],
);
const handleAddTeamTemplate = useCallback(
() => addTeamTemplateSection(locale),
[addTeamTemplateSection, locale],
);
const handleAddBlogTemplate = useCallback(() => addBlogTemplateSection(), [addBlogTemplateSection]);
const handleAddTeamTemplate = useCallback(() => addTeamTemplateSection(), [addTeamTemplateSection]);
const handleAddFooterTemplate = useCallback(
() => addFooterTemplateSection(),
[addFooterTemplateSection],
() => addFooterTemplateSection(locale),
[addFooterTemplateSection, locale],
);
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
const [isFormsOpen, setIsFormsOpen] = useState(true);
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
return (
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll">
<h2 className="font-medium"></h2>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddText}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddButton}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddImage}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddVideo}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddDivider}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddList}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddSection}
>
</button>
<aside
data-testid="blocks-sidebar"
className="w-60 border-r border-slate-200 bg-white p-4 text-sm space-y-3 overflow-y-auto pb-scroll dark:border-slate-800 dark:bg-slate-950/40"
>
<h2 className="font-medium flex items-center gap-2 text-slate-900 dark:text-slate-100">
<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>{m.blocksTitle}</span>
</span>
</button>
</h2>
{isBlocksOpen && (
<>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddText}
>
<span className="inline-flex items-center gap-2">
<Type className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksText}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddButton}
>
<span className="inline-flex items-center gap-2">
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksButton}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddImage}
>
<span className="inline-flex items-center gap-2">
<ImageIcon className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksImage}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddVideo}
>
<span className="inline-flex items-center gap-2">
<Video className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksVideo}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddDivider}
>
<span className="inline-flex items-center gap-2">
<Minus className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksDivider}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddList}
>
<span className="inline-flex items-center gap-2">
<List className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksList}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddSection}
>
<span className="inline-flex items-center gap-2">
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
<span>{m.blocksSection}</span>
</span>
</button>
</>
)}
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300"> </h3>
<button
type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={handleAddFormBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormInput}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormSelect}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormRadio}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormCheckbox}
>
</button>
<div className="pt-3 border-t border-slate-200 mt-3 space-y-2 dark:border-slate-800">
<h3 className="text-[11px] font-medium text-slate-800 flex items-center gap-2 dark:text-slate-200">
<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>{m.formsTitle}</span>
</span>
</button>
</h3>
{isFormsOpen && (
<>
<button
type="button"
className="w-full rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-left text-xs text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-900"
onClick={handleAddFormBlock}
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>{m.formsController}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFormInput}
>
<span className="inline-flex items-center gap-2">
<FileText className="w-3 h-3" aria-hidden="true" />
<span>{m.formsInput}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFormSelect}
>
<span className="inline-flex items-center gap-2">
<ListFilter className="w-3 h-3" aria-hidden="true" />
<span>{m.formsSelect}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFormRadio}
>
<span className="inline-flex items-center gap-2">
<CircleDot className="w-3 h-3" aria-hidden="true" />
<span>{m.formsRadio}</span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFormCheckbox}
>
<span className="inline-flex items-center gap-2">
<SquareCheck className="w-3 h-3" aria-hidden="true" />
<span>{m.formsCheckbox}</span>
</span>
</button>
</>
)}
</div>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
<h3 className="text-[11px] font-medium text-slate-300">릿</h3>
<div className="pt-3 border-t border-slate-200 mt-3 space-y-3 dark:border-slate-800">
<h3 className="text-[11px] font-medium text-slate-800 flex items-center gap-2 dark:text-slate-200">
<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>{m.templatesTitle}</span>
</span>
</button>
</h3>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> · CTA</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddHeroTemplate}
>
Hero 릿
</button>
<div
data-testid="template-preview-hero"
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
<div className="h-[2px] w-3/4 bg-slate-700/70" />
<div className="h-[2px] w-1/2 bg-slate-700/50" />
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
Hero ( + + )
</p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddCtaTemplate}
>
CTA 릿
</button>
<div
data-testid="template-preview-cta"
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full items-center gap-[2px]">
<div className="flex-1 flex flex-col justify-center gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-2/3 bg-slate-700/50" />
{isTemplatesOpen && (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupHeroCta}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddHeroTemplate}
>
{m.templateHeroLabel}
</button>
<div
data-testid="template-preview-hero"
className="shrink-0 flex h-6 w-10 rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
<div className="h-[2px] w-3/4 bg-slate-700/70" />
<div className="h-[2px] w-1/2 bg-slate-700/50" />
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
</div>
</div>
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
</div>
<p className="text-[10px] text-slate-400 leading-snug">{m.templateHeroDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddCtaTemplate}
>
{m.templateCtaLabel}
</button>
<div
data-testid="template-preview-cta"
className="shrink-0 flex h-6 w-10 rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateCtaDescription}</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupContent}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFeaturesTemplate}
>
{m.templateFeaturesLabel}
</button>
<div
data-testid="template-preview-features"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateFeaturesDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFaqTemplate}
>
{m.templateFaqLabel}
</button>
<div
data-testid="template-preview-faq"
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateFaqDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddPricingTemplate}
>
{m.templatePricingLabel}
</button>
<div
data-testid="template-preview-pricing"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templatePricingDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddBlogTemplate}
>
{m.templateBlogLabel}
</button>
<div
data-testid="template-preview-blog"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateBlogDescription}</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupTrust}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddTestimonialsTemplate}
>
{m.templateTestimonialsLabel}
</button>
<div
data-testid="template-preview-testimonials"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateTestimonialsDescription}</p>
</div>
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddTeamTemplate}
>
{m.templateTeamLabel}
</button>
<div
data-testid="template-preview-team"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateTeamDescription}</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-600 dark:text-slate-400">{m.templatesGroupFooter}</p>
<div className="space-y-2">
<div className="rounded border border-slate-200 bg-slate-50 p-2 space-y-1 dark:border-slate-800 dark:bg-slate-950/50">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleAddFooterTemplate}
>
{m.templateFooterLabel}
</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-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
>
<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">{m.templateFooterDescription}</p>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">(CTA) </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> </p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFeaturesTemplate}
>
Features 릿
</button>
<div
data-testid="template-preview-features"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFaqTemplate}
>
FAQ 릿
</button>
<div
data-testid="template-preview-faq"
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="w-2 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col gap-[2px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddPricingTemplate}
>
Pricing 릿
</button>
<div
data-testid="template-preview-pricing"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-sky-500/70" />
<div className="h-[1px] w-3/4 bg-sky-500/50" />
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddBlogTemplate}
>
Blog 릿
</button>
<div
data-testid="template-preview-blog"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTestimonialsTemplate}
>
Testimonials 릿
</button>
<div
data-testid="template-preview-testimonials"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTeamTemplate}
>
Team 릿
</button>
<div
data-testid="template-preview-team"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFooterTemplate}
>
Footer 릿
</button>
<div
data-testid="template-preview-footer"
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 h-[2px] bg-slate-700/70" />
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 h-[1px] bg-slate-700/40" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
)}
</div>
</aside>
);
+221 -68
View File
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorButtonPanelMessages } from "@/features/i18n/messages/editorButtonPanel";
export type ButtonPropertiesPanelProps = {
buttonProps: ButtonBlockProps;
@@ -11,14 +13,23 @@ export type ButtonPropertiesPanelProps = {
};
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorButtonPanelMessages(locale);
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>
<span>{m.buttonTextLabel}</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="버튼 텍스트"
className="w-full min-h-[60px] rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.buttonTextAria}
value={buttonProps.label}
onChange={(e) => {
updateBlock(selectedBlockId, { label: e.target.value } as any);
@@ -26,9 +37,151 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
/>
</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-800 dark:text-slate-200">{m.imageSectionTitle}</h4>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span>{m.imageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.imageSourceAria}
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">{m.imageSourceOptionNone}</option>
<option value="url">{m.imageSourceOptionUrl}</option>
<option value="upload">{m.imageSourceOptionUpload}</option>
</select>
</label>
</div>
{imageSource === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span>{m.imageUrlLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.imageUrlAria}
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>{m.imageUploadLabel}</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={m.imageUploadAria}
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("Button image upload failed", 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 while uploading button image", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
</div>
)}
{imageSource !== "none" && (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span>{m.imageAltLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.imageAltAria}
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>{m.imagePlacementLabel}</span>
<select
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.imagePlacementAria}
value={buttonProps.imagePlacement ?? "left"}
onChange={(e) => {
updateBlock(selectedBlockId, {
imagePlacement: e.target.value as any,
} as any);
}}
>
<option value="left">{m.imagePlacementOptionLeft}</option>
<option value="right">{m.imagePlacementOptionRight}</option>
<option value="top">{m.imagePlacementOptionTop}</option>
<option value="bottom">{m.imagePlacementOptionBottom}</option>
</select>
</label>
</div>
</>
)}
</div>
<div className="space-y-1">
<NumericPropertyControl
label="가로 패딩 (px)"
label={m.paddingXLabel}
unitLabel="(px)"
value={buttonProps.paddingX ?? 16}
min={0}
@@ -50,7 +203,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="세로 패딩 (px)"
label={m.paddingYLabel}
unitLabel="(px)"
value={buttonProps.paddingY ?? 10}
min={0}
@@ -72,10 +225,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.linkLabel}</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="버튼 링크"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.linkAria}
value={buttonProps.href}
onChange={(e) => {
updateBlock(selectedBlockId, { href: e.target.value } as any);
@@ -85,19 +238,19 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.alignLabel}</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="버튼 정렬"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignAria}
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>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
@@ -106,13 +259,13 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="버튼 텍스트 색상 피커"
ariaLabelHexInput="버튼 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== ""
? buttonProps.textColorCustom
: "#f9fafb"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -129,31 +282,31 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.styleLabel}</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="버튼 스타일"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.styleAria}
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>
<option value="solid">{m.styleOptionSolid}</option>
<option value="outline">{m.styleOptionOutline}</option>
<option value="ghost">{m.styleOptionGhost}</option>
</select>
</label>
</div>
<div className="space-y-1">
<ColorPickerField
label="채움 색상"
ariaLabelColorInput="버튼 채움 색상 피커"
ariaLabelHexInput="버튼 채움 색상 HEX"
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== ""
? buttonProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -170,13 +323,13 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<ColorPickerField
label="외곽선 색상"
ariaLabelColorInput="버튼 외곽선 색상 피커"
ariaLabelHexInput="버튼 외곽선 색상 HEX"
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== ""
? buttonProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -193,7 +346,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
const r = buttonProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
@@ -202,11 +355,11 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
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 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 1 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 2 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 3 },
{ id: "full", label: m.borderRadiusPresetFull, value: 4 },
]}
onChangeValue={(v) => {
const next =
@@ -217,10 +370,10 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.widthModeLabel}</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="버튼 너비 모드"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.widthModeAria}
value={buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")}
onChange={(e) => {
updateBlock(selectedBlockId, {
@@ -228,23 +381,23 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
} as any);
}}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")) === "fixed" && (
<NumericPropertyControl
label="버튼 고정 너비"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
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 },
{ id: "sm", label: m.fixedWidthPresetSmall, value: 180 },
{ id: "md", label: m.fixedWidthPresetMedium, value: 240 },
{ id: "lg", label: m.fixedWidthPresetLarge, value: 320 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -256,8 +409,8 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="버튼 크기"
unitLabel="(px)"
label={m.fontSizeLabel}
unitLabel={m.fontSizeUnitLabel}
value={(() => {
const raw = buttonProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -288,7 +441,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
label={m.lineHeightLabel}
value={(() => {
const raw = buttonProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -299,9 +452,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.1 },
{ id: "normal", label: "보통", value: 1.4 },
{ id: "relaxed", label: "넓게", value: 1.8 },
{ id: "tight", label: m.lineHeightPresetTight, value: 1.1 },
{ id: "normal", label: m.lineHeightPresetNormal, value: 1.4 },
{ id: "relaxed", label: m.lineHeightPresetRelaxed, value: 1.8 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -312,8 +465,8 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
</div>
<div className="space-y-1">
<NumericPropertyControl
label="글자 간격"
unitLabel="(px)"
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = buttonProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -323,15 +476,15 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
}
return 0;
})()}
min={-2}
max={10}
step={0.1}
min={-32}
max={32}
step={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 },
{ id: "tighter", label: m.letterSpacingPresetTighter, value: -1.5 },
{ id: "tight", label: m.letterSpacingPresetTight, value: -0.5 },
{ id: "normal", label: m.letterSpacingPresetNormal, value: 0 },
{ id: "wide", label: m.letterSpacingPresetWide, value: 1 },
{ id: "wider", label: m.letterSpacingPresetWider, value: 2 },
]}
onChangeValue={(px) => {
const em = px / 16;
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorDividerPropertiesPanelMessages } from "@/features/i18n/messages/editorDividerPropertiesPanel";
export type DividerPropertiesPanelProps = {
dividerProps: DividerBlockProps;
@@ -11,77 +13,80 @@ export type DividerPropertiesPanelProps = {
};
export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBlock }: DividerPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorDividerPropertiesPanelMessages(locale);
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.alignLabel}</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="구분선 정렬"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignAria}
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>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.thicknessLabel}</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="구분선 두께"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.thicknessAria}
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>
<option value="thin">{m.thicknessOptionThin}</option>
<option value="medium">{m.thicknessOptionMedium}</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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 길이/너비 모드 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.lengthModeLabel}</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="구분선 길이 모드"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.lengthModeAria}
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>
<option value="auto">{m.lengthModeOptionAuto}</option>
<option value="full">{m.lengthModeOptionFull}</option>
<option value="fixed">{m.lengthModeOptionFixed}</option>
</select>
</label>
{(dividerProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="고정 길이 (px)"
unitLabel="(px)"
label={m.fixedLengthLabel}
unitLabel={m.fixedLengthUnitLabel}
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 },
{ id: "sm", label: m.fixedLengthPresetShort, value: 240 },
{ id: "md", label: m.fixedLengthPresetNormal, value: 320 },
{ id: "lg", label: m.fixedLengthPresetLong, value: 480 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { widthPx: v } as any);
@@ -91,13 +96,13 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
{/* 색상 */}
<ColorPickerField
label="선 색상"
ariaLabelColorInput="구분선 색상 피커"
ariaLabelHexInput="구분선 색상 HEX"
label={m.colorLabel}
ariaLabelColorInput={m.colorPickerAria}
ariaLabelHexInput={m.colorHexAria}
value={
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
? dividerProps.colorHex
: TEXT_COLOR_PALETTE[0]?.color ?? "#64748b"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, { colorHex: hex } as any);
@@ -110,8 +115,8 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
{/* 상하 여백 (슬라이더) */}
<NumericPropertyControl
label="위/아래 여백"
unitLabel="(px)"
label={m.marginLabel}
unitLabel={m.marginUnitLabel}
value={(() => {
if (typeof dividerProps.marginYPx === "number") {
return dividerProps.marginYPx;
@@ -123,9 +128,9 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
max={50}
step={2}
presets={[
{ id: "sm", label: "작게", value: 8 },
{ id: "md", label: "보통", value: 16 },
{ id: "lg", label: "크게", value: 24 },
{ id: "sm", label: m.marginPresetSmall, value: 8 },
{ id: "md", label: m.marginPresetNormal, value: 16 },
{ id: "lg", label: m.marginPresetLarge, value: 24 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { marginYPx: v } as any);
+45 -41
View File
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorImagePanelMessages } from "@/features/i18n/messages/editorImagePanel";
export type ImagePropertiesPanelProps = {
imageProps: ImageBlockProps;
@@ -11,6 +13,8 @@ export type ImagePropertiesPanelProps = {
};
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorImagePanelMessages(locale);
const source: "url" | "upload" =
imageProps.sourceType === "asset" || (imageProps.src && imageProps.src.startsWith("/api/image/"))
? "upload"
@@ -20,10 +24,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.imageSourceLabel}</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="이미지 소스"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.imageSourceAria}
value={source}
onChange={(e) => {
const next = e.target.value as "url" | "upload";
@@ -39,8 +43,8 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
}
}}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.imageSourceOptionUrl}</option>
<option value="upload">{m.imageSourceOptionUpload}</option>
</select>
</label>
</div>
@@ -48,10 +52,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> URL</span>
<span>{m.imageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.imageUrlAria}
value={imageProps.src}
onChange={(e) => {
const value = e.target.value;
@@ -69,12 +73,12 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{source === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.imageUploadLabel}</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="이미지 파일 업로드"
aria-label={m.imageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -89,7 +93,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
});
if (!response.ok) {
console.error("이미지 업로드 실패", await response.text());
console.error("Image upload failed", await response.text());
return;
}
@@ -102,7 +106,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
assetId: data.id,
} as any);
} catch (error) {
console.error("이미지 업로드 중 오류", error);
console.error("Error while uploading image", error);
} finally {
event.target.value = "";
}
@@ -113,10 +117,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
)}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.altLabel}</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="대체 텍스트"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.altAria}
value={imageProps.alt}
onChange={(e) => {
updateBlock(selectedBlockId, { alt: e.target.value } as any);
@@ -126,14 +130,14 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
</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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 카드 배경색 */}
<div className="space-y-1">
<ColorPickerField
label="카드 배경색"
ariaLabelColorInput="이미지 카드 배경색 피커"
ariaLabelHexInput="이미지 카드 배경색 HEX"
label={m.cardBackgroundLabel}
ariaLabelColorInput={m.cardBackgroundPickerAria}
ariaLabelHexInput={m.cardBackgroundHexAria}
value={imageProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
@@ -145,10 +149,10 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{/* 정렬 */}
<label className="flex flex-col gap-1">
<span></span>
<span>{m.alignLabel}</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="이미지 정렬"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignAria}
value={imageProps.align ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -156,18 +160,18 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
{/* 너비 모드 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.widthModeLabel}</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="이미지 너비 모드"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.widthModeAria}
value={imageProps.widthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -175,23 +179,23 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="auto"> </option>
<option value="fixed"> (px)</option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(imageProps.widthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="고정 너비 (px)"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
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 },
{ id: "sm", label: m.fixedWidthPresetSmall, value: 240 },
{ id: "md", label: m.fixedWidthPresetMedium, value: 320 },
{ id: "lg", label: m.fixedWidthPresetLarge, value: 480 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -203,7 +207,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
{/* 모서리 둥글기 */}
<NumericPropertyControl
label="모서리 둥글기"
label={m.borderRadiusLabel}
value={(() => {
if (typeof imageProps.borderRadiusPx === "number") {
return imageProps.borderRadiusPx;
@@ -216,11 +220,11 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
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 },
{ id: "none", label: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 20 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 60 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 120 },
{ id: "full", label: m.borderRadiusPresetFull, value: 180 },
]}
onChangeValue={(v) => {
// 0~50 범위를 토큰으로 매핑한다.
+49 -43
View File
@@ -10,6 +10,8 @@ import {
} from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorListPanelMessages } from "@/features/i18n/messages/editorListPanel";
export type ListPropertiesPanelProps = {
listProps: ListBlockProps;
@@ -24,14 +26,16 @@ export function ListPropertiesPanel({
selectedListItemId,
updateBlock,
}: ListPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorListPanelMessages(locale);
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> ( )</span>
<span>{m.listItemsLabel}</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="리스트 아이템들"
className="w-full min-h-[80px] rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.listItemsAria}
value={(() => {
const tree = (listProps as any).itemsTree as any[] | undefined;
@@ -83,30 +87,30 @@ export function ListPropertiesPanel({
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<label className="flex items-center gap-2">
<span></span>
<span>{m.alignLabel}</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="리스트 정렬"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignAria}
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>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 글자 크기 */}
<NumericPropertyControl
label="글자 크기 (px)"
unitLabel="(px)"
label={m.fontSizeLabel}
unitLabel={m.fontSizeUnitLabel}
value={(() => {
const raw = listProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
@@ -117,9 +121,9 @@ export function ListPropertiesPanel({
max={32}
step={1}
presets={[
{ id: "sm", label: "작게", value: 12 },
{ id: "md", label: "보통", value: 14 },
{ id: "lg", label: "크게", value: 18 },
{ id: "sm", label: m.fontSizePresetSmall, value: 12 },
{ id: "md", label: m.fontSizePresetMedium, value: 14 },
{ id: "lg", label: m.fontSizePresetLarge, value: 18 },
]}
onChangeValue={(px) => {
updateBlock(selectedBlockId, { fontSizeCustom: `${px}px` } as any);
@@ -128,7 +132,7 @@ export function ListPropertiesPanel({
{/* 줄 간격 */}
<NumericPropertyControl
label="줄 간격"
label={m.lineHeightLabel}
value={(() => {
const raw = listProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
@@ -139,9 +143,9 @@ export function ListPropertiesPanel({
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.2 },
{ id: "normal", label: "보통", value: 1.5 },
{ id: "relaxed", label: "넓게", value: 1.8 },
{ id: "tight", label: m.lineHeightPresetTight, value: 1.2 },
{ id: "normal", label: m.lineHeightPresetNormal, value: 1.5 },
{ id: "relaxed", label: m.lineHeightPresetRelaxed, value: 1.8 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { lineHeightCustom: v.toString() } as any);
@@ -150,13 +154,15 @@ export function ListPropertiesPanel({
{/* 텍스트 색상 */}
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="리스트 텍스트 색상 피커"
ariaLabelHexInput="리스트 텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
// textColorCustom 이 비어 있으면 커스텀 색상을 사용하지 않고, "없음" 상태로 취급한다.
// 이 경우 HEX 인풋은 빈 문자열을 유지하고, 팔레트 라벨은 "없음"으로 표시된다.
value={
listProps.textColorCustom && listProps.textColorCustom.trim() !== ""
? listProps.textColorCustom
: "#e5e7eb"
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, { textColorCustom: hex } as any);
@@ -168,9 +174,9 @@ export function ListPropertiesPanel({
/>
<ColorPickerField
label="블록 배경색"
ariaLabelColorInput="리스트 배경색 피커"
ariaLabelHexInput="리스트 배경색 HEX"
label={m.backgroundColorLabel}
ariaLabelColorInput={m.backgroundColorPickerAria}
ariaLabelHexInput={m.backgroundColorHexAria}
value={listProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
@@ -180,10 +186,10 @@ export function ListPropertiesPanel({
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
<label className="flex flex-col gap-1">
<span>릿 </span>
<span>{m.bulletStyleLabel}</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="리스트 불릿 스타일"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.bulletStyleAria}
value={listProps.bulletStyle ?? "disc"}
onChange={(e) => {
const value = e.target.value as NonNullable<ListBlockProps["bulletStyle"]>;
@@ -201,22 +207,22 @@ export function ListPropertiesPanel({
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>
<option value="disc">{m.bulletStyleOptionDisc}</option>
<option value="circle">{m.bulletStyleOptionCircle}</option>
<option value="square">{m.bulletStyleOptionSquare}</option>
<option value="decimal">{m.bulletStyleOptionDecimal}</option>
<option value="lower-alpha">{m.bulletStyleOptionLowerAlpha}</option>
<option value="upper-alpha">{m.bulletStyleOptionUpperAlpha}</option>
<option value="lower-roman">{m.bulletStyleOptionLowerRoman}</option>
<option value="upper-roman">{m.bulletStyleOptionUpperRoman}</option>
<option value="none">{m.bulletStyleOptionNone}</option>
</select>
</label>
{/* 아이템 간 여백 (슬라이더) */}
<NumericPropertyControl
label="아이템 간 여백 (px)"
unitLabel="(px)"
label={m.gapLabel}
unitLabel={m.gapUnitLabel}
value={(() => {
if (typeof listProps.gapYPx === "number") {
return listProps.gapYPx;
@@ -228,9 +234,9 @@ export function ListPropertiesPanel({
max={40}
step={2}
presets={[
{ id: "tight", label: "좁게", value: 4 },
{ id: "normal", label: "보통", value: 8 },
{ id: "relaxed", label: "넓게", value: 16 },
{ id: "tight", label: m.gapPresetTight, value: 4 },
{ id: "normal", label: m.gapPresetNormal, value: 8 },
{ id: "relaxed", label: m.gapPresetRelaxed, value: 16 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, { gapYPx: v } as any);
@@ -4,6 +4,8 @@ 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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorProjectPropertiesPanelMessages } from "@/features/i18n/messages/editorProjectPropertiesPanel";
export function ProjectPropertiesPanel() {
const projectConfig = useEditorStore((state) => (state as any).projectConfig as ProjectConfig);
@@ -11,6 +13,9 @@ export function ProjectPropertiesPanel() {
(state) => (state as any).updateProjectConfig as (partial: Partial<ProjectConfig>) => void,
);
const locale = useAppLocale();
const m = getEditorProjectPropertiesPanelMessages(locale);
const handleChangePreset = (preset: CanvasPreset) => {
if (preset === "mobile") {
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 390 });
@@ -38,15 +43,15 @@ export function ProjectPropertiesPanel() {
};
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-4 text-xs text-slate-900 dark:text-slate-200">
<h3 className="text-sm font-medium text-slate-100">{m.sectionTitle}</h3>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.projectTitleLabel}</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="프로젝트 제목"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.projectTitleAria}
value={projectConfig.title}
onChange={(e) => updateProjectConfig({ title: e.target.value })}
/>
@@ -55,10 +60,10 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> (slug)</span>
<span className="text-slate-500 dark:text-slate-400">{m.projectSlugLabel}</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)"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.projectSlugAria}
value={projectConfig.slug}
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
/>
@@ -67,16 +72,16 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<NumericPropertyControl
label="캔버스 너비"
unitLabel="(px)"
label={m.canvasWidthLabel}
unitLabel={m.canvasWidthUnitLabel}
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 },
{ id: "mobile", label: m.canvasWidthPresetMobile, value: 390 },
{ id: "tablet", label: m.canvasWidthPresetTablet, value: 768 },
{ id: "desktop", label: m.canvasWidthPresetDesktop, value: 1200 },
]}
onChangeValue={handleChangeCanvasWidth}
/>
@@ -84,9 +89,9 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<ColorPickerField
label="캔버스 배경색"
ariaLabelColorInput="캔버스 배경색"
ariaLabelHexInput="캔버스 배경색 HEX"
label={m.canvasBgLabel}
ariaLabelColorInput={m.canvasBgColorAria}
ariaLabelHexInput={m.canvasBgHexAria}
value={projectConfig.canvasBgColorHex ?? "#020617"}
onChange={(hex) => updateProjectConfig({ canvasBgColorHex: hex })}
palette={TEXT_COLOR_PALETTE}
@@ -95,9 +100,9 @@ export function ProjectPropertiesPanel() {
<div className="space-y-1">
<ColorPickerField
label="페이지 배경색"
ariaLabelColorInput="페이지 배경색"
ariaLabelHexInput="페이지 배경색 HEX"
label={m.pageBgLabel}
ariaLabelColorInput={m.pageBgColorAria}
ariaLabelHexInput={m.pageBgHexAria}
value={projectConfig.bodyBgColorHex ?? "#020617"}
onChange={(hex) => updateProjectConfig({ bodyBgColorHex: hex })}
palette={TEXT_COLOR_PALETTE}
@@ -105,86 +110,86 @@ export function ProjectPropertiesPanel() {
</div>
<div className="space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-200">SEO / </h4>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.seoSectionTitle}</h4>
<label className="flex flex-col gap-1">
<span className="text-slate-400">SEO </span>
<span className="text-slate-500 dark:text-slate-400">{m.seoTitleLabel}</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 || "페이지 제목"}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.seoTitleAria}
placeholder={projectConfig.title || m.seoTitlePlaceholderFallback}
value={projectConfig.seoTitle ?? ""}
onChange={(e) => updateProjectConfig({ seoTitle: e.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.seoDescriptionLabel}</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 공유에 노출될 페이지 설명을 입력하세요."
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 min-h-[56px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.seoDescriptionAria}
placeholder={m.seoDescriptionPlaceholder}
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>
<span className="text-slate-500 dark:text-slate-400">{m.seoOgImageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.seoOgImageUrlAria}
placeholder={m.seoOgImageUrlPlaceholder}
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>
<span className="text-slate-500 dark:text-slate-400">{m.seoCanonicalUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.seoCanonicalUrlAria}
placeholder={m.seoCanonicalUrlPlaceholder}
value={projectConfig.seoCanonicalUrl ?? ""}
onChange={(e) => updateProjectConfig({ seoCanonicalUrl: e.target.value })}
/>
</label>
<label className="flex items-center gap-2 text-[11px] text-slate-300">
<label className="flex items-center gap-2 text-[11px] text-slate-600 dark:text-slate-300">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-600 bg-slate-900"
aria-label="검색 엔진에 노출하지 않기 (noindex)"
className="h-3 w-3 rounded border-slate-300 bg-white text-sky-600 dark:border-slate-600 dark:bg-slate-900"
aria-label={m.seoNoIndexAria}
checked={Boolean(projectConfig.seoNoIndex)}
onChange={(e) => updateProjectConfig({ seoNoIndex: e.target.checked })}
/>
<span> (noindex)</span>
<span>{m.seoNoIndexLabel}</span>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> head HTML</span>
<span className="text-slate-500 dark:text-slate-400">{m.headHtmlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 min-h-[72px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.headHtmlAria}
value={projectConfig.headHtml ?? ""}
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
placeholder="예: &lt;meta name=&quot;description&quot; content=&quot;...&quot; /&gt;"
placeholder={m.headHtmlPlaceholder}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.trackingScriptLabel}</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="추적 스크립트"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 min-h-[72px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.trackingScriptAria}
value={projectConfig.trackingScript ?? ""}
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
placeholder="예: &lt;script&gt;/* GA, Pixel 코드 */&lt;/script&gt;"
placeholder={m.trackingScriptPlaceholder}
/>
</label>
</div>
+114 -6
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./TextPropertiesPanel";
@@ -14,6 +15,20 @@ import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPane
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel";
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
import { Trash2, Copy, SlidersHorizontal, HelpCircle } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorPropertiesSidebarMessages } from "@/features/i18n/messages/editorPropertiesSidebar";
type BlockHelpProperty = {
label: string;
description: string;
};
type BlockHelpContent = {
title: string;
description: string;
properties?: BlockHelpProperty[];
};
interface PropertiesSidebarProps {
blocks: Block[];
@@ -48,32 +63,90 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
onOutdentSelectedItem,
} = props;
const [helpOpen, setHelpOpen] = useState(false);
const locale = useAppLocale();
const m = getEditorPropertiesSidebarMessages(locale);
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 m.text;
case "button":
return m.button;
case "image":
return m.image;
case "list":
return m.list;
case "divider":
return m.divider;
case "section":
return m.section;
case "video":
return m.video;
case "form":
return m.form;
case "formInput":
return m.formInput;
case "formSelect":
return m.formSelect;
case "formCheckbox":
return m.formCheckbox;
case "formRadio":
return m.formRadio;
default:
return m.fallback;
}
};
return (
<aside
data-testid="properties-sidebar"
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll"
className="w-80 p-4 text-sm border-l border-slate-200 bg-white flex flex-col gap-4 overflow-auto pb-scroll dark:border-slate-800 dark:bg-slate-950/40"
onKeyDownCapture={(e) => {
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
e.stopPropagation();
}}
>
<h2 className="font-medium mb-2"> </h2>
<h2 className="font-medium mb-2 flex items-center gap-2 text-slate-900 dark:text-slate-100">
<SlidersHorizontal className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span>{m.panelTitle}</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"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-2 py-1 text-slate-900 hover:bg-red-50 hover:border-red-300 hover:text-red-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark: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>{m.actionDeleteBlock}</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"
className="flex-1 rounded border border-slate-200 bg-slate-50 px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark: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>{m.actionDuplicateBlock}</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-200 bg-slate-50 px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={() => setHelpOpen(true)}
>
<HelpCircle className="w-3 h-3" aria-hidden="true" />
<span>{m.actionHelp}</span>
</button>
</div>
{(() => {
@@ -224,6 +297,41 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
) : (
<ProjectPropertiesPanel />
)}
{helpOpen && (() => {
const help = getHelpContentForSelectedBlock();
if (!help) return null;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
<div className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-4 text-xs text-slate-900 shadow-xl dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">{help.title}</h3>
<button
type="button"
className="text-slate-600 dark:text-slate-400 hover:text-slate-100 text-xs"
onClick={() => setHelpOpen(false)}
>
{m.modalCloseLabel}
</button>
</div>
<p className="text-[11px] text-slate-600 dark: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-900 dark:text-slate-200">{m.modalPropertiesSectionTitle}</h4>
<ul className="space-y-1">
{help.properties.map((prop) => (
<li key={prop.label}>
<div className="text-[11px] font-semibold text-slate-600 dark:text-slate:-100">{prop.label}</div>
<div className="text-[11px] text-slate-500 dark:text-slate-300">{prop.description}</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
})()}
</aside>
);
}
+101 -97
View File
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorSectionPanelMessages } from "@/features/i18n/messages/editorSectionPanel";
export type SectionPropertiesPanelProps = {
sectionProps: SectionBlockProps;
@@ -11,6 +13,8 @@ export type SectionPropertiesPanelProps = {
};
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorSectionPanelMessages(locale);
const backgroundSource: "none" | "url" | "upload" = (() => {
const src = sectionProps.backgroundImageSrc?.trim();
const type = sectionProps.backgroundImageSourceType;
@@ -44,9 +48,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
<div className="mt-1">
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="섹션 배경 색상 선택"
ariaLabelHexInput="섹션 배경 색상 HEX 입력"
label={m.backgroundColorLabel}
ariaLabelColorInput={m.backgroundColorPickerAria}
ariaLabelHexInput={m.backgroundColorHexAria}
value={sectionProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
@@ -59,10 +63,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 섹션 배경 이미지 */}
<div className="mt-3 space-y-2 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageSourceLabel}</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="배경 이미지 소스"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundImageSourceAria}
value={backgroundSource}
onChange={(e) => {
const next = e.target.value as "none" | "url" | "upload";
@@ -84,18 +88,18 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}
}}
>
<option value="none"></option>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="none">{m.backgroundImageSourceOptionNone}</option>
<option value="url">{m.backgroundImageSourceOptionUrl}</option>
<option value="upload">{m.backgroundImageSourceOptionUpload}</option>
</select>
</label>
{backgroundSource === "url" && (
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.backgroundImageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundImageUrlAria}
value={sectionProps.backgroundImageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -111,12 +115,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundSource === "upload" && (
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageUploadLabel}</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="배경 이미지 파일 업로드"
aria-label={m.backgroundImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -155,10 +159,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundSource !== "none" && (
<>
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImagePositionModeLabel}</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="배경 이미지 위치 모드"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundImagePositionModeAria}
value={positionMode}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -166,16 +170,16 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
} as any)
}
>
<option value="preset"></option>
<option value="custom"> (X/Y)</option>
<option value="preset">{m.backgroundImagePositionModeOptionPreset}</option>
<option value="custom">{m.backgroundImagePositionModeOptionCustom}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageSizeLabel}</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="배경 이미지 크기"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundImageSizeAria}
value={sectionProps.backgroundImageSize ?? "cover"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -191,10 +195,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{positionMode === "preset" && (
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImagePositionLabel}</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="배경 이미지 위치"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundImagePositionAria}
value={sectionProps.backgroundImagePosition ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -202,11 +206,11 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
} as any)
}
>
<option value="center"></option>
<option value="top"></option>
<option value="bottom"></option>
<option value="left"></option>
<option value="right"></option>
<option value="center">{m.backgroundImagePositionOptionCenter}</option>
<option value="top">{m.backgroundImagePositionOptionTop}</option>
<option value="bottom">{m.backgroundImagePositionOptionBottom}</option>
<option value="left">{m.backgroundImagePositionOptionLeft}</option>
<option value="right">{m.backgroundImagePositionOptionRight}</option>
</select>
</label>
)}
@@ -214,7 +218,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{positionMode === "custom" && (
<>
<NumericPropertyControl
label="배경 이미지 가로 위치"
label={m.backgroundImagePositionXLabel}
unitLabel="(%)"
value={
typeof sectionProps.backgroundImagePositionXPercent === "number"
@@ -225,9 +229,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={100}
step={1}
presets={[
{ id: "left", label: "왼쪽", value: 0 },
{ id: "center", label: "가운데", value: 50 },
{ id: "right", label: "오른쪽", value: 100 },
{ id: "left", label: m.backgroundImagePositionOptionLeft, value: 0 },
{ id: "center", label: m.backgroundImagePositionOptionCenter, value: 50 },
{ id: "right", label: m.backgroundImagePositionOptionRight, value: 100 },
]}
onChangeValue={(next) =>
updateBlock(selectedBlockId, {
@@ -238,7 +242,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
/>
<NumericPropertyControl
label="배경 이미지 세로 위치"
label={m.backgroundImagePositionYLabel}
unitLabel="(%)"
value={
typeof sectionProps.backgroundImagePositionYPercent === "number"
@@ -249,9 +253,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={100}
step={1}
presets={[
{ id: "top", label: "위", value: 0 },
{ id: "center", label: "가운데", value: 50 },
{ id: "bottom", label: "아래", value: 100 },
{ id: "top", label: m.backgroundImagePositionOptionTop, value: 0 },
{ id: "center", label: m.backgroundImagePositionOptionCenter, value: 50 },
{ id: "bottom", label: m.backgroundImagePositionOptionBottom, value: 100 },
]}
onChangeValue={(next) =>
updateBlock(selectedBlockId, {
@@ -264,10 +268,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
)}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundImageRepeatLabel}</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="배경 이미지 반복"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundImageRepeatAria}
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -275,10 +279,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
} as any)
}
>
<option value="no-repeat"> </option>
<option value="repeat">/ </option>
<option value="repeat-x"> </option>
<option value="repeat-y"> </option>
<option value="no-repeat">{m.backgroundImageRepeatOptionNone}</option>
<option value="repeat">{m.backgroundImageRepeatOptionBoth}</option>
<option value="repeat-x">{m.backgroundImageRepeatOptionX}</option>
<option value="repeat-y">{m.backgroundImageRepeatOptionY}</option>
</select>
</label>
</>
@@ -288,10 +292,10 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 섹션 배경 비디오 */}
<div className="mt-3 space-y-2 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundVideoSourceLabel}</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="배경 비디오 소스"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundVideoSourceAria}
value={backgroundVideoSource}
onChange={(e) => {
const next = e.target.value as "none" | "url" | "upload";
@@ -313,18 +317,18 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}
}}
>
<option value="none"></option>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="none">{m.backgroundVideoSourceOptionNone}</option>
<option value="url">{m.backgroundVideoSourceOptionUrl}</option>
<option value="upload">{m.backgroundVideoSourceOptionUpload}</option>
</select>
</label>
{backgroundVideoSource === "url" && (
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.backgroundVideoUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.backgroundVideoUrlAria}
value={sectionProps.backgroundVideoSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -340,12 +344,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{backgroundVideoSource === "upload" && (
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.backgroundVideoUploadLabel}</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="배경 비디오 파일 업로드"
aria-label={m.backgroundVideoUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -383,21 +387,21 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
)}
</div>
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
{/* 세로 패딩 슬라이더 - 프리셋 + 자유 슬라이더 */}
<div className="mt-3 space-y-1">
<NumericPropertyControl
label="세로 패딩"
unitLabel="(px)"
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
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 },
{ id: "x-tight", label: m.paddingYPresetExtraTight, value: 16 },
{ id: "tight", label: m.paddingYPresetTight, value: 32 },
{ id: "normal", label: m.paddingYPresetNormal, value: 48 },
{ id: "relaxed", label: m.paddingYPresetRelaxed, value: 64 },
{ id: "x-relaxed", label: m.paddingYPresetExtraRelaxed, value: 80 },
]}
onChangeValue={(next) => {
const safe = Number.isFinite(next) ? next : 48;
@@ -407,14 +411,14 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
</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>
<h4 className="text-[11px] font-semibold text-slate-200">{m.layoutSectionTitle}</h4>
{/* 컬럼 레이아웃 프리셋 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.columnLayoutLabel}</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="섹션 컬럼 레이아웃"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.columnLayoutAria}
value={(() => {
const cols = sectionProps.columns ?? [];
const spans = cols.map((c) => c.span).join("-");
@@ -471,12 +475,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
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>
<option value="one-full">{m.columnLayoutOptionOneFull}</option>
<option value="two-equal">{m.columnLayoutOptionTwoEqual}</option>
<option value="two-1-2">{m.columnLayoutOptionTwoOneTwo}</option>
<option value="two-2-1">{m.columnLayoutOptionTwoTwoOne}</option>
<option value="three-equal">{m.columnLayoutOptionThreeEqual}</option>
<option value="custom">{m.columnLayoutOptionCustom}</option>
</select>
</label>
@@ -678,20 +682,20 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
});
})()}
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
{/* 최대 폭 슬라이더 - 프리셋 + 자유 슬라이더 */}
<NumericPropertyControl
label="최대 폭 (px)"
unitLabel="(px)"
label={m.maxWidthLabel}
unitLabel={m.maxWidthUnitLabel}
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 },
{ id: "x-narrow", label: m.maxWidthPresetExtraNarrow, value: 640 },
{ id: "narrow", label: m.maxWidthPresetNarrow, value: 800 },
{ id: "normal", label: m.maxWidthPresetNormal, value: 960 },
{ id: "wide", label: m.maxWidthPresetWide, value: 1200 },
{ id: "x-wide", label: m.maxWidthPresetExtraWide, value: 1440 },
]}
onChangeValue={(next) => {
const safe = Number.isFinite(next) ? next : 960;
@@ -699,21 +703,21 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}}
/>
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
{/* 컬럼 간 간격 슬라이더 - 프리셋 + 자유 슬라이더 */}
<NumericPropertyControl
label="컬럼 간 간격 (px)"
unitLabel="(px)"
label={m.gapXLabel}
unitLabel={m.gapXUnitLabel}
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 },
{ id: "zero", label: m.gapXPresetZero, value: 0 },
{ id: "tight", label: m.gapXPresetTight, value: 16 },
{ id: "normal", label: m.gapXPresetNormal, value: 24 },
{ id: "relaxed", label: m.gapXPresetRelaxed, value: 32 },
{ id: "x-relaxed", label: m.gapXPresetExtraRelaxed, value: 48 },
{ id: "max", label: m.gapXPresetMax, value: 64 },
]}
onChangeValue={(next) => {
const safe = Number.isFinite(next) ? next : 24;
@@ -723,19 +727,19 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
{/* 세로 정렬 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.alignItemsLabel}</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="섹션 컬럼 세로 정렬"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignItemsAria}
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>
<option value="top">{m.alignItemsOptionTop}</option>
<option value="center">{m.alignItemsOptionCenter}</option>
<option value="bottom">{m.alignItemsOptionBottom}</option>
</select>
</label>
</div>
+59 -55
View File
@@ -4,6 +4,8 @@ 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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorTextPanelMessages } from "@/features/i18n/messages/editorTextPanel";
export type TextPropertiesPanelProps = {
textProps: TextBlockProps;
@@ -20,6 +22,8 @@ export function TextPropertiesPanel({
editingBlockId,
setEditingText,
}: TextPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorTextPanelMessages(locale);
const fontSizeMode = textProps.fontSizeMode ?? "scale";
const fallbackScale =
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
@@ -97,10 +101,10 @@ export function TextPropertiesPanel({
return (
<>
<div className="space-y-1">
<p className="text-xs text-slate-400"> </p>
<p className="text-xs text-slate-400">{m.selectedTextLabel}</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="선택한 텍스트 블록 내용"
className="w-full min-h-[80px] rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.selectedTextAria}
value={textProps.text}
onChange={(e) => {
const value = e.target.value;
@@ -114,33 +118,33 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<span>{m.alignLabel}</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="정렬"
className="rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignAria}
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>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span> </span>
<span>{m.textStyleLabel}</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"
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
}`}
onClick={() => {
updateBlock(selectedBlockId, {
@@ -148,14 +152,14 @@ export function TextPropertiesPanel({
} as any);
}}
>
{m.underlineLabel}
</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"
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
}`}
onClick={() => {
updateBlock(selectedBlockId, {
@@ -163,14 +167,14 @@ export function TextPropertiesPanel({
} as any);
}}
>
{m.strikeLabel}
</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"
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
}`}
onClick={() => {
updateBlock(selectedBlockId, {
@@ -178,7 +182,7 @@ export function TextPropertiesPanel({
} as any);
}}
>
{m.italicLabel}
</button>
</div>
</div>
@@ -186,8 +190,8 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<NumericPropertyControl
label="글자 크기"
unitLabel="(px)"
label={m.fontSizeLabel}
unitLabel={m.fontSizeUnitLabel}
value={Number.isFinite(parsedFontSize) ? parsedFontSize : 16}
min={10}
max={72}
@@ -243,11 +247,11 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.letterSpacingLabel}</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="글자 간격 프리셋"
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.letterSpacingPresetAria}
value={letterSpacingPreset}
onChange={(e) => {
const preset = e.target.value as
@@ -276,17 +280,17 @@ export function TextPropertiesPanel({
} as any);
}}
>
<option value="tighter"> </option>
<option value="tight"></option>
<option value="normal"></option>
<option value="wide"></option>
<option value="wider"> </option>
<option value="tighter">{m.letterSpacingPresetTighter}</option>
<option value="tight">{m.letterSpacingPresetTight}</option>
<option value="normal">{m.letterSpacingPresetNormal}</option>
<option value="wide">{m.letterSpacingPresetWide}</option>
<option value="wider">{m.letterSpacingPresetWider}</option>
</select>
<PropertySliderField
label=""
ariaLabelSlider="글자 간격 슬라이더"
ariaLabelInput="글자 간격 커스텀 (px)"
ariaLabelSlider={m.letterSpacingSliderAria}
ariaLabelInput={m.letterSpacingInputAria}
value={Number.isFinite(parsedLetterSpacingPx) ? parsedLetterSpacingPx : 0}
min={-2}
max={10}
@@ -304,18 +308,18 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
label={m.lineHeightLabel}
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 },
{ id: "tight", label: m.lineHeightPresetTight, value: 1.25 },
{ id: "snug", label: m.lineHeightPresetSnug, value: 1.35 },
{ id: "normal", label: m.lineHeightPresetNormal, value: 1.5 },
{ id: "relaxed", label: m.lineHeightPresetRelaxed, value: 1.7 },
{ id: "loose", label: m.lineHeightPresetLoose, value: 1.9 },
]}
onChangeValue={(v) => {
const preset: Record<NonNullable<TextBlockProps["lineHeightScale"]>, number> = {
@@ -350,7 +354,7 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<NumericPropertyControl
label="굵기"
label={m.fontWeightLabel}
value={Number.isFinite(parsedFontWeight) ? parsedFontWeight : 400}
min={100}
max={900}
@@ -363,12 +367,12 @@ export function TextPropertiesPanel({
id: scale,
label:
scale === "normal"
? "보통"
? m.fontWeightPresetNormal
: scale === "medium"
? "중간"
? m.fontWeightPresetMedium
: scale === "semibold"
? "세미볼드"
: "볼드",
? m.fontWeightPresetSemibold
: m.fontWeightPresetBold,
value:
scale === "normal"
? 400
@@ -410,9 +414,9 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="텍스트 색상 피커"
ariaLabelHexInput="텍스트 색상 HEX"
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
(textProps.colorCustom && textProps.colorCustom.startsWith("#")
? textProps.colorCustom
@@ -439,9 +443,9 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<ColorPickerField
label="블록 배경색"
ariaLabelColorInput="텍스트 블록 배경색 피커"
ariaLabelHexInput="텍스트 블록 배경색 HEX"
label={m.backgroundColorLabel}
ariaLabelColorInput={m.backgroundColorPickerAria}
ariaLabelHexInput={m.backgroundColorHexAria}
value={textProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
updateBlock(selectedBlockId, {
@@ -454,11 +458,11 @@ export function TextPropertiesPanel({
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.maxWidthLabel}</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="최대 너비 프리셋"
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.maxWidthPresetAria}
value={maxWidthScale}
onChange={(e) => {
const value = e.target.value as NonNullable<TextBlockProps["maxWidthScale"]>;
@@ -473,16 +477,16 @@ export function TextPropertiesPanel({
} as any);
}}
>
<option value="none"> </option>
<option value="prose"> (60ch)</option>
<option value="narrow"> (40ch)</option>
<option value="none">{m.maxWidthPresetNone}</option>
<option value="prose">{m.maxWidthPresetProse}</option>
<option value="narrow">{m.maxWidthPresetNarrow}</option>
</select>
<input
type="range"
min={20}
max={120}
step={5}
aria-label="최대 너비 슬라이더 (ch 단위)"
aria-label={m.maxWidthSliderAria}
value={(() => {
const raw = textProps.maxWidthCustom ?? "";
const match = raw.match(/([0-9]+)ch/);
@@ -502,9 +506,9 @@ export function TextPropertiesPanel({
/>
</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.maxWidthCustomAria}
placeholder={m.maxWidthPlaceholder}
value={textProps.maxWidthCustom ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, {
+59 -55
View File
@@ -3,6 +3,8 @@
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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorVideoPanelMessages } from "@/features/i18n/messages/editorVideoPanel";
export type VideoPropertiesPanelProps = {
videoProps: VideoBlockProps;
@@ -11,6 +13,8 @@ export type VideoPropertiesPanelProps = {
};
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorVideoPanelMessages(locale);
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
const source: "url" | "upload" =
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
@@ -22,10 +26,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 비디오 소스 선택 (URL / 업로드) */}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.sourceLabel}</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="비디오 소스"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.sourceAria}
value={source}
onChange={(e) => {
const next = e.target.value as "url" | "upload";
@@ -41,8 +45,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
}
}}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.sourceOptionUrl}</option>
<option value="upload">{m.sourceOptionUpload}</option>
</select>
</label>
</div>
@@ -51,10 +55,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> URL</span>
<span>{m.urlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.urlAria}
value={videoProps.sourceUrl ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -70,10 +74,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
<div className="hidden">
<label className="flex flex-col gap-1 mt-2">
<span> </span>
<span>{m.titleLabel}</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={m.titleAria}
value={(videoProps as any).titleText ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
@@ -82,10 +86,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
</label>
<label className="mt-2 flex flex-col gap-1">
<span> aria-label</span>
<span>{m.ariaLabelLabel}</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"
aria-label={m.ariaLabelAria}
value={(videoProps as any).ariaLabel ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
@@ -94,10 +98,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
</label>
<label className="mt-2 flex flex-col gap-1">
<span> </span>
<span>{m.captionTextLabel}</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={m.captionTextAria}
value={(videoProps as any).captionText ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
@@ -110,10 +114,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
<div className="mt-3 space-y-1 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span> URL</span>
<span>{m.posterImageUrlLabel}</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.posterImageUrlAria}
value={videoProps.posterImageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
@@ -131,12 +135,12 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{source === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<span>{m.uploadLabel}</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="비디오 파일 업로드"
aria-label={m.uploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -175,14 +179,14 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
)}
<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>
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
{/* 카드 배경색 */}
<div className="space-y-1">
<ColorPickerField
label="카드 배경색"
ariaLabelColorInput="비디오 카드 배경색 피커"
ariaLabelHexInput="비디오 카드 배경색 HEX"
label={m.cardBackgroundLabel}
ariaLabelColorInput={m.cardBackgroundPickerAria}
ariaLabelHexInput={m.cardBackgroundHexAria}
value={videoProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
@@ -194,10 +198,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 정렬 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.alignLabel}</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="비디오 정렬"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.alignAria}
value={videoProps.align ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -205,18 +209,18 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="left">{m.alignOptionLeft}</option>
<option value="center">{m.alignOptionCenter}</option>
<option value="right">{m.alignOptionRight}</option>
</select>
</label>
{/* 너비 모드 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.widthModeLabel}</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="비디오 너비 모드"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.widthModeAria}
value={videoProps.widthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -224,24 +228,24 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
>
<option value="auto"> </option>
<option value="full"> </option>
<option value="fixed"> (px)</option>
<option value="auto">{m.widthModeOptionAuto}</option>
<option value="full">{m.widthModeOptionFull}</option>
<option value="fixed">{m.widthModeOptionFixed}</option>
</select>
</label>
{(videoProps.widthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="고정 너비 (px)"
unitLabel="(px)"
label={m.fixedWidthLabel}
unitLabel={m.fixedWidthUnitLabel}
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 },
{ id: "sm", label: m.fixedWidthPresetSmall, value: 480 },
{ id: "md", label: m.fixedWidthPresetMedium, value: 640 },
{ id: "lg", label: m.fixedWidthPresetLarge, value: 960 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -253,8 +257,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 카드 패딩 */}
<NumericPropertyControl
label="카드 패딩"
unitLabel="(px)"
label={m.cardPaddingLabel}
unitLabel={m.cardPaddingUnitLabel}
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
min={0}
max={64}
@@ -267,8 +271,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 카드 모서리 둥글기 */}
<NumericPropertyControl
label="카드 모서리 둥글기"
unitLabel="(px)"
label={m.cardBorderRadiusLabel}
unitLabel={m.cardBorderRadiusUnitLabel}
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
min={0}
max={64}
@@ -281,8 +285,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 시작 시점 (초) */}
<NumericPropertyControl
label="시작 시점 (초)"
unitLabel="(초)"
label={m.startTimeLabel}
unitLabel={m.startTimeUnitLabel}
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
min={0}
max={600}
@@ -295,8 +299,8 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 종료 시점 (초) */}
<NumericPropertyControl
label="종료 시점 (초)"
unitLabel="(초)"
label={m.endTimeLabel}
unitLabel={m.endTimeUnitLabel}
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
min={0}
max={600}
@@ -309,10 +313,10 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
{/* 화면 비율 */}
<label className="flex flex-col gap-1">
<span> </span>
<span>{m.aspectRatioLabel}</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="화면 비율"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={m.aspectRatioAria}
value={videoProps.aspectRatio ?? "16:9"}
onChange={(e) =>
updateBlock(selectedBlockId, {
@@ -339,7 +343,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"> </span>
<span className="text-[11px]">{m.autoplayLabel}</span>
</label>
<label className="flex items-center gap-1">
<input
@@ -352,7 +356,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"> </span>
<span className="text-[11px]">{m.loopLabel}</span>
</label>
<label className="flex items-center gap-1">
<input
@@ -365,7 +369,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"></span>
<span className="text-[11px]">{m.mutedLabel}</span>
</label>
<label className="flex items-center gap-1">
<input
@@ -378,7 +382,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
} as any)
}
/>
<span className="text-[11px]"> </span>
<span className="text-[11px]">{m.controlsLabel}</span>
</label>
</div>
</div>
+4 -8
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
import type { EditorBlogTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createBlogTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorBlogTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,14 +35,8 @@ export function createBlogTemplateBlocks(opts: {
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 post = messages.posts[index] ?? messages.posts[0];
const imageId = createId();
const titleId = createId();
+5 -3
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
import type { EditorCtaTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createCtaTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorCtaTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 8 },
@@ -37,7 +39,7 @@ export function createCtaTemplateBlocks(opts: {
const textId = createId();
const textProps: TextBlockProps = {
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
text: messages.body,
align: "left",
size: "lg",
} as any;
@@ -51,7 +53,7 @@ export function createCtaTemplateBlocks(opts: {
const buttonId = createId();
const buttonProps: ButtonBlockProps = {
label: "CTA 버튼",
label: messages.buttonLabel,
href: "#",
align: "center",
size: "lg",
+6 -19
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorFaqTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createFaqTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorFaqTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 3 },
@@ -38,7 +40,7 @@ export function createFaqTemplateBlocks(opts: {
// Left Column: Title
const titleId = createId();
const titleProps: TextBlockProps = {
text: "FAQ",
text: messages.title,
align: "left",
size: "xl",
bold: true,
@@ -53,7 +55,7 @@ export function createFaqTemplateBlocks(opts: {
const subTitleId = createId();
const subTitleProps: TextBlockProps = {
text: "자주 묻는 질문",
text: messages.subtitle,
align: "left",
size: "sm",
color: "muted",
@@ -68,22 +70,7 @@ export function createFaqTemplateBlocks(opts: {
// Right Column: Q&A List
const faqPairs = [
{
question: "서비스 이용료는 얼마인가요?",
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
},
{
question: "환불 정책은 어떻게 되나요?",
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
},
{
question: "팀원 초대 기능이 있나요?",
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
},
];
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
const faqBlocks: Block[] = messages.items.flatMap((pair) => {
const qId = createId();
const aId = createId();
+5 -3
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorFeaturesTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createFeaturesTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorFeaturesTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -38,13 +40,13 @@ export function createFeaturesTemplateBlocks(opts: {
const descId = createId();
const titleProps: TextBlockProps = {
text: `Feature ${index + 1} 제목`,
text: `${messages.itemTitlePrefix} ${index + 1}${messages.itemTitleSuffix}`.trim(),
align: "left",
size: "lg",
} as any;
const descProps: TextBlockProps = {
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
text: messages.itemDescription,
align: "left",
size: "sm",
} as any;
+5 -3
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorFooterTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createFooterTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorFooterTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -54,7 +56,7 @@ export function createFooterTemplateBlocks(opts: {
const descId = createId();
const descProps: TextBlockProps = {
text: "더 나은 웹사이트를 위한 최고의 선택.",
text: messages.description,
align: "left",
size: "sm",
color: "muted",
@@ -70,7 +72,7 @@ export function createFooterTemplateBlocks(opts: {
// Col 2: Links
const linksId = createId();
const linksProps: TextBlockProps = {
text: "서비스 소개\n요금제\n고객지원\n문의하기",
text: messages.linksText,
align: "center",
size: "sm",
color: "muted",
+6 -4
View File
@@ -6,12 +6,14 @@ import type {
TextBlockProps,
ButtonBlockProps,
} from "@/features/editor/state/editorStore";
import type { EditorHeroTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createHeroTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorHeroTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const sectionProps: SectionBlockProps = {
background: "default",
@@ -41,7 +43,7 @@ export function createHeroTemplateBlocks(opts: {
const heroHeadlineId = createId();
const heroHeadlineProps: TextBlockProps = {
text: "Hero 제목을 여기에 입력하세요",
text: messages.headline,
align: "center",
size: "lg",
} as any;
@@ -55,7 +57,7 @@ export function createHeroTemplateBlocks(opts: {
const heroSubId = createId();
const heroSubProps: TextBlockProps = {
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
text: messages.subheadline,
align: "center",
size: "base",
} as any;
@@ -69,7 +71,7 @@ export function createHeroTemplateBlocks(opts: {
const heroButtonId = createId();
const heroButtonProps: ButtonBlockProps = {
label: "지금 시작하기",
label: messages.primaryButtonLabel,
href: "#",
align: "center",
size: "md",
+6 -8
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
import type { EditorPricingTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createPricingTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorPricingTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,11 +35,7 @@ export function createPricingTemplateBlocks(opts: {
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 planDefinitions = messages.plans;
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
const plan = planDefinitions[index] ?? planDefinitions[0];
@@ -96,7 +94,7 @@ export function createPricingTemplateBlocks(opts: {
// Features list (simple text for now)
const featuresId = createId();
const featuresProps: TextBlockProps = {
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
text: messages.featuresText,
align: "center",
size: "sm",
color: "muted",
@@ -112,7 +110,7 @@ export function createPricingTemplateBlocks(opts: {
// Button
const buttonId = createId();
const buttonProps: ButtonBlockProps = {
label: isPopular ? "시작하기" : "선택하기",
label: isPopular ? messages.primaryButtonLabel : messages.secondaryButtonLabel,
href: "#",
align: "center",
size: "md",
+4 -8
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
import type { EditorTeamTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createTeamTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorTeamTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,14 +35,8 @@ export function createTeamTemplateBlocks(opts: {
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 m = messages.members[index] ?? messages.members[0];
const imageId = createId();
const nameId = createId();
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorTestimonialsTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createTestimonialsTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorTestimonialsTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,11 +35,7 @@ export function createTestimonialsTemplateBlocks(opts: {
columnId: null,
};
const testimonialDefinitions: Array<{ body: string; author: string }> = [
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
];
const testimonialDefinitions = messages.items;
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
+20 -4
View File
@@ -1,17 +1,33 @@
import "../styles/globals.css";
import "../styles/builder.css";
import type { ReactNode } from "react";
import { headers, cookies } from "next/headers";
import { LocaleProvider } from "@/features/i18n/LocaleProvider";
import { LocaleSwitcher } from "@/features/i18n/LocaleSwitcher";
import { resolveLocaleFromAcceptLanguage, DEFAULT_LOCALE } from "@/features/i18n/locale";
export const metadata = {
title: "Page Builder",
description: "No-code single page builder",
};
export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("pb-locale")?.value;
const headerList = await headers();
const acceptLanguage = headerList.get("accept-language");
const localeFromCookie = cookieLocale === "en" || cookieLocale === "ko" ? cookieLocale : null;
const locale = (localeFromCookie ?? resolveLocaleFromAcceptLanguage(acceptLanguage ?? undefined)) ?? DEFAULT_LOCALE;
return (
<html lang="ko" suppressHydrationWarning>
<body className="min-h-screen bg-slate-950 text-slate-50">
{children}
<html lang={locale} suppressHydrationWarning>
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<LocaleProvider initialLocale={locale}>
{children}
<LocaleSwitcher />
</LocaleProvider>
</body>
</html>
);
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getAuthMessages } from "@/features/i18n/messages/auth";
// 로그인 페이지 컴포넌트
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
export default function LoginPage() {
const router = useRouter();
const locale = useAppLocale();
const { login: t } = getAuthMessages(locale);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleToggleTheme = () => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
};
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/dashboard");
}
} 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 ?? t.errorLoginFailed);
} catch {
setError(t.errorLoginFailed);
}
setLoading(false);
return;
}
// 성공 시에는 /dashboard 로 이동한다.
router.push("/dashboard");
} catch {
setError(t.errorNetwork);
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900/70">
<div className="flex items-center justify-between mb-1">
<h1 className="text-lg font-semibold">{t.title}</h1>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span>{t.themeToggleLabel}</span>
</button>
</div>
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400">{t.description}</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-800 dark:text-slate-200">
{t.emailLabel}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-800 dark:text-slate-200">
{t.passwordLabel}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
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 ? t.submitLoading : t.submitIdle}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-500 dark:text-slate-400">
{t.signupPromptPrefix}
{" "}
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
{t.signupLinkText}
</Link>
{t.signupPromptSuffix && " "}
{t.signupPromptSuffix}
</p>
</div>
</main>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPageRenderer";
interface PublicProjectPageClientProps {
html: string;
}
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
const m = getPublicPageRendererMessages(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
useEffect(() => {
if (typeof document === "undefined" || typeof window === "undefined") return;
let toastTimeout: number | null = null;
const showToast = (message: string) => {
setToastMessage(message);
if (toastTimeout !== null) {
window.clearTimeout(toastTimeout);
}
toastTimeout = window.setTimeout(() => {
setToastMessage(null);
}, 4000);
};
const forms = document.querySelectorAll<HTMLFormElement>(
'form.pb-form-controller, form[action="/api/forms/submit"]',
);
if (!forms || forms.length === 0) return;
forms.forEach((form) => {
if (form.dataset.pbInitialized === "1") return;
form.dataset.pbInitialized = "1";
form.addEventListener("submit", (event) => {
if (!form) return;
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
const formData = new FormData(form);
const configInput = form.querySelector<HTMLInputElement>('input[name="__config"]');
let config: any = null;
if (configInput && configInput.value) {
try {
config = JSON.parse(configInput.value);
} catch {
config = null;
}
}
const action = form.getAttribute("action") || "/api/forms/submit";
fetch(action, { method: "POST", body: formData })
.then((res) => res.json().catch(() => ({})))
.then((data: any) => {
const ok = data && data.ok;
const message = data && data.message;
if (ok) {
const success = message || (config && config.successMessage) || m.submitSuccessDefault;
showToast(success);
try {
form.reset();
} catch {}
} else {
const errorMsg = message || (config && config.errorMessage) || m.submitErrorDefault;
showToast(errorMsg);
}
})
.catch(() => {
const errorMsg = (config && config.errorMessage) || m.submitErrorDefault;
showToast(errorMsg);
});
});
});
return () => {
if (toastTimeout !== null) {
window.clearTimeout(toastTimeout);
}
};
}, []);
return (
<>
<div dangerouslySetInnerHTML={{ __html: html }} />
{toastMessage ? (
<div
style={{
position: "fixed",
right: "16px",
bottom: "16px",
zIndex: 50,
maxWidth: "320px",
padding: "12px 16px",
borderRadius: "8px",
backgroundColor: "rgba(15, 23, 42, 0.95)",
color: "#e5e7eb",
fontSize: "14px",
lineHeight: "1.4",
boxShadow: "0 10px 25px rgba(15, 23, 42, 0.6)",
pointerEvents: "none",
}}
>
{toastMessage}
</div>
) : null}
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { notFound } from "next/navigation";
import { PrismaClient } from "@prisma/client";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
import { getCachedPublicProject } from "@/features/projects/publicCache";
import PublicProjectPageClient from "./PublicProjectPageClient";
const prisma = new PrismaClient();
interface PageParams {
// Next.js App Router 에서는 params 가 Promise 로 전달되므로
// 서버 컴포넌트에서 사용하기 전에 await 로 한 번 풀어줘야 한다.
params: Promise<{ slug: string }>;
}
// /p/[slug] 공개 페이지
// - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다.
// - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다.
// - 인증 없이 접근 가능하며, 폼 제출은 /api/forms/submit 을 통해 정적 Export 와 동일하게 동작한다.
export default async function PublicProjectPage({ params }: PageParams) {
// Next 가 넘겨주는 params 는 Promise 이므로, 먼저 await 해서 slug 를 꺼낸다.
const resolvedParams = await params;
const slugRaw = resolvedParams.slug ?? "";
const slug = slugRaw.toString().trim();
if (!slug) {
notFound();
}
// 1차로 메모리 기반 퍼블릭 캐시에서 스냅샷을 조회한다.
const cached = getCachedPublicProject(slug);
let blocks: Block[];
let title: string;
if (cached) {
blocks = cached.contentJson ?? [];
title = cached.title ?? "";
} else {
const project = await prisma.project.findUnique({
where: { slug },
select: {
title: true,
slug: true,
contentJson: true,
},
});
if (!project) {
notFound();
}
const rawContent = project.contentJson;
let contentBlocks: Block[] = [];
if (Array.isArray(rawContent)) {
contentBlocks = rawContent as unknown as Block[];
}
blocks = contentBlocks;
title = project.title;
}
// Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다.
const projectConfig: ProjectConfig = {
title,
slug,
canvasPreset: "full",
canvasBgColorHex: "#020617",
bodyBgColorHex: "#020617",
} as ProjectConfig;
const fullHtml = buildStaticHtml(blocks, projectConfig);
// buildStaticHtml 이 생성한 index.html 에서 <main> ... </main> 블록만 추출해 렌더링한다.
// 이렇게 하면 Export index.html 과 동일한 main/app-root DOM 구조를 재사용할 수 있다.
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
const mainHtml = mainMatch ? mainMatch[0] : "";
return <PublicProjectPageClient html={mainHtml} />;
}
+14 -7
View File
@@ -1,12 +1,19 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function HomePage() {
const router = useRouter();
useEffect(() => {
// 메인 페이지 진입 시 대시보드로 안내한다.
router.push("/dashboard");
}, [router]);
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-3xl font-bold">Page Builder MVP</h1>
<p className="text-sm text-slate-400">
TDD로 .
</p>
</div>
<main className="flex min-h-screen items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<p className="text-sm text-slate-500 dark:text-slate-400"> ...</p>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import "../../styles/editor.css";
export default function PreviewLayout({ children }: { children: ReactNode }) {
// 프리뷰 화면에서도 에디터 크롬과 동일한 전용 스타일(editor.css)을 사용한다.
// 콘텐츠 블록 자체의 모양은 builder.css(pb-*) 기준으로 렌더된다.
return children;
}
+163 -15
View File
@@ -1,27 +1,155 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getPreviewMessages } from "@/features/i18n/messages/preview";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
export default function PreviewPage() {
const router = useRouter();
const locale = useAppLocale();
const m = getPreviewMessages(locale);
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;
const configSlugRaw = (projectConfig as any)?.slug?.trim?.();
const configSlug =
typeof configSlugRaw === "string" && configSlugRaw.length > 0 ? configSlugRaw : "";
let querySlug = "";
try {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get("slug")?.trim();
if (fromQuery) {
querySlug = fromQuery;
}
} catch {
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
}
const effectiveSlug = querySlug || configSlug;
if (!effectiveSlug) return;
const key = `pb:autosave:${effectiveSlug}`;
const raw = window.localStorage.getItem(key);
if (!raw) {
// autosave 스냅샷이 없을 때만 URL 쿼리 슬러그를 projectConfig.slug 에 동기화한다.
if (querySlug && querySlug !== configSlug) {
updateProjectConfig({ slug: querySlug } as any);
}
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);
} else if (querySlug && querySlug !== configSlug) {
// 스냅샷에 projectConfig 가 없을 때만 쿼리 슬러그를 반영한다.
updateProjectConfig({ slug: querySlug } 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 mainStyle: CSSProperties = {};
{
const raw = typeof projectConfig?.bodyBgColorHex === "string" ? projectConfig.bodyBgColorHex.trim() : "";
if (raw) {
mainStyle.backgroundColor = raw;
}
}
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;
const projectSlugRaw = (projectConfig as any)?.slug?.trim?.();
const projectSlug =
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
const editorHref = projectSlug
? `/editor?slug=${encodeURIComponent(projectSlug)}`
: "/editor";
if (widthPx != null) {
canvasStyle.maxWidth = `${widthPx}px`;
} else if (preset === "mobile") {
@@ -37,18 +165,35 @@ export default function PreviewPage() {
}
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">
<main className="min-h-screen flex flex-col" style={mainStyle}>
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-xl font-semibold">Page Preview</h1>
<p className="text-xs text-slate-400"> </p>
<h1 className="text-xl font-semibold">{m.headerTitle}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
</div>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={() => {
void handleExportZip();
}}
>
{m.exportZipButtonLabel}
</button>
<Link
href="/projects"
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-xs text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{m.navProjects}
</Link>
<Link
href={editorHref}
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-xs text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{m.navBackToEditor}
</Link>
</div>
<Link
href="/editor"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
>
</Link>
</header>
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
<section className="flex flex-1 overflow-auto">
@@ -58,7 +203,10 @@ export default function PreviewPage() {
className="w-full"
style={canvasStyle}
>
<PublicPageRenderer blocks={blocks} />
<PublicPageRenderer
blocks={blocks}
projectSlug={(projectConfig?.slug ?? "").trim() || undefined}
/>
</div>
</div>
</section>
@@ -0,0 +1,274 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useParams } from "next/navigation";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
// - 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 locale = useAppLocale();
const t = getDashboardMessages(locale);
const m = getSubmissionsMessages(locale);
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// 마운트 시 현재 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(m.errorUnauthorized);
router.push("/login");
return;
}
if (res.status === 404) {
setStatus("error");
setErrorMessage(m.errorGeneric);
return;
}
setStatus("error");
setErrorMessage(m.errorGeneric);
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(m.errorGeneric);
}
}
};
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");
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
setErrorMessage(m.errorLogout);
return;
}
router.push("/login");
} catch {
setErrorMessage(m.errorLogout);
}
};
const handleToggleTheme = () => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
};
return (
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.headerTitle}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
<p className="mt-1 text-[11px] font-mono text-slate-500 dark:text-slate-400">{slug}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<Link
href="/dashboard"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
<button
type="button"
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen(false);
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && errorMessage && (
<p className="text-xs text-red-500 mb-3 dark:text-red-300">{errorMessage}</p>
)}
{status === "loading" && (
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400">{m.loadingText}</p>
)}
{submissions.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400">{m.emptyText}</p>
)}
{submissions.length > 0 && (
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
<th className="py-2 pr-4">{m.tableHeaderCreatedAt}</th>
<th className="py-2 pr-4">{m.tableHeaderName}</th>
<th className="py-2 pr-4">{m.tableHeaderEmail}</th>
<th className="py-2 pr-4">{m.tableHeaderPhone}</th>
<th className="py-2 pr-4">{m.tableHeaderBirthdate}</th>
<th className="py-2 pr-4">{m.tableHeaderOtherFields}</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-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{name}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{email}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{phone}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{birthdate}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 whitespace-pre-wrap">
{renderOtherFields(payload)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</main>
);
}
+488
View File
@@ -0,0 +1,488 @@
"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,
LayoutDashboard,
FolderKanban,
SunMoon,
} from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getProjectsMessages } from "@/features/i18n/messages/projects";
interface ProjectListItem {
id: string;
title: string;
slug: string;
status: string;
createdAt: string;
updatedAt: string;
}
export default function ProjectsPage() {
const router = useRouter();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const p = getProjectsMessages(locale);
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 [isMenuOpen, setIsMenuOpen] = useState(false);
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(p.errorFetchUnauthorized);
router.push("/login");
} else {
setErrorMessage(p.errorFetchGeneric);
}
}
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(p.errorFetchGeneric);
}
}
};
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(p.confirmDeleteSingle);
if (!confirmed) return;
}
try {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
method: "DELETE",
});
if (!res.ok) {
setStatus("error");
setErrorMessage(p.errorDeleteSingle);
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(p.errorDeleteSingle);
}
};
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(p.confirmDeleteBulk);
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(p.errorDeleteBulkAllFailed);
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(p.errorDeleteBulkSomeFailed);
} else {
setStatus("idle");
setErrorMessage(null);
}
} catch {
setStatus("error");
setErrorMessage(p.errorDeleteBulkAllFailed);
}
};
const handleToggleTheme = () => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
};
return (
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight">{p.headerTitle}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{p.headerDescription}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<Link
href="/dashboard"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
<button
type="button"
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen(false);
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && (
<p className="text-xs text-red-500 mb-3 dark:text-red-300">
{errorMessage ?? p.errorFetchGeneric}
</p>
)}
{projects.length === 0 && status === "idle" && (
<p className="text-xs text-slate-500 dark:text-slate-400"> . .</p>
)}
{projects.length > 0 && (
<>
<div
data-testid="projects-toolbar"
className="flex items-center justify-between mb-2 text-[11px] text-slate-600 dark: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 border bg-slate-100 border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
<ListChecks className="w-3 h-3 text-slate-400 dark:text-slate-500" aria-hidden="true" />
<span>
{p.toolbarSelectedPrefix}
<span className="font-semibold">{selectedSlugs.length}</span>
{p.toolbarSelectedSuffix}
</span>
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-red-300 text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-40 disabled:cursor-not-allowed dark:border-red-700 dark:text-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60"
disabled={selectedSlugs.length === 0}
onClick={() => {
void handleBulkDelete();
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span>{p.toolbarDeleteSelected}</span>
</button>
<Link
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-sky-500 bg-sky-600 text-white hover:bg-sky-700 shadow-sm dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
>
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>{p.toolbarCreateNew}</span>
</Link>
</div>
</div>
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark: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={p.tableHeaderSelectAllAria}
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">{p.tableHeaderTitle}</th>
<th className="py-2 pr-4">{p.tableHeaderSlug}</th>
<th className="py-2 pr-4">{p.tableHeaderStatus}</th>
<th className="py-2 pr-4">{p.tableHeaderCreatedAt}</th>
<th className="py-2 pr-4">{p.tableHeaderUpdatedAt}</th>
<th className="py-2 pr-4 text-right">{p.tableHeaderActions}</th>
</tr>
</thead>
<tbody>
{pageProjects.map((project) => {
const checked = selectedSlugs.includes(project.slug);
return (
<tr
key={project.id}
className="border-b border-slate-200 hover:bg-slate-50 dark:border-slate-900 dark: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}${p.tableRowSelectAriaSuffix}`}
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-900 dark:text-slate-100">{project.title}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 font-mono text-[11px]">{project.slug}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{project.status}</td>
<td className="py-2 pr-4 text-slate-500 dark:text-slate-400 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-600 hover:text-sky-700 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
>
<Pencil className="w-3 h-3" aria-hidden="true" />
<span>{p.actionEdit}</span>
</Link>
<Link
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-800 underline-offset-2 hover:underline dark:text-slate-300 dark:hover:text-slate-100"
>
<Eye className="w-3 h-3" aria-hidden="true" />
<span>{p.actionPreview}</span>
</Link>
<Link
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
>
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span>{p.actionViewSubmissions}</span>
</Link>
<button
type="button"
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 underline-offset-2 hover:underline dark:text-red-300 dark:hover:text-red-200"
onClick={() => {
void handleDelete(project.slug);
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span>{p.actionDelete}</span>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-600 dark:text-slate-300">
<div className="inline-flex items-center gap-2">
<span className="px-2 py-1 rounded-full bg-slate-100 border border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
{p.pagerTotalPrefix}
{totalCount}
{p.pagerTotalCountSuffix}
<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-white text-slate-700 border-slate-300 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-700 dark: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-300 text-slate-700 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
<span>{p.pagerPrevLabel}</span>
</button>
<button
type="button"
disabled={currentPage === totalPages}
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-300 text-slate-700 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
>
<span>{p.pagerNextLabel}</span>
<ChevronRight className="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
</>
)}
</section>
</main>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
interface SubmissionItem {
id: string;
createdAt: string;
projectSlug: string;
projectTitle: string | null;
payload: Record<string, unknown>;
}
type PageStatus = "idle" | "loading" | "error";
export default function AllProjectSubmissionsPage() {
const router = useRouter();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const m = getSubmissionsMessages(locale);
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch("/api/projects/submissions");
if (!res.ok) {
if (cancelled) return;
if (res.status === 401) {
setStatus("error");
setErrorMessage(m.errorUnauthorized);
router.push("/login");
return;
}
setStatus("error");
setErrorMessage(m.errorGeneric);
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(m.errorGeneric);
}
}
};
void load();
return () => {
cancelled = true;
};
}, []);
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");
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
setErrorMessage(m.errorLogout);
return;
}
router.push("/login");
} catch {
setErrorMessage(m.errorLogout);
}
};
return (
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.headerTitle}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<Link
href="/dashboard"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-emerald-600 text-white shadow-sm hover:bg-emerald-700"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
}}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
<button
type="button"
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
setIsMenuOpen(false);
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && errorMessage && (
<p className="text-xs text-red-500 mb-3 dark:text-red-300">{errorMessage}</p>
)}
{status === "loading" && (
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400">{m.loadingText}</p>
)}
{status === "idle" && !errorMessage && submissions.length === 0 && (
<p className="text-xs text-slate-500 dark:text-slate-400">{m.emptyText}</p>
)}
{submissions.length > 0 && (
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
<th className="py-2 pr-4">{m.tableHeaderCreatedAt}</th>
<th className="py-2 pr-4">{m.tableHeaderProject}</th>
<th className="py-2 pr-4">{m.tableHeaderSlug}</th>
<th className="py-2 pr-4">{m.tableHeaderName}</th>
<th className="py-2 pr-4">{m.tableHeaderEmail}</th>
<th className="py-2 pr-4">{m.tableHeaderPhone}</th>
<th className="py-2 pr-4">{m.tableHeaderBirthdate}</th>
<th className="py-2 pr-4">{m.tableHeaderOtherFields}</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-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{item.projectTitle ?? "-"}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{item.projectSlug}</td>
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{name}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{email}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{phone}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{birthdate}</td>
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 whitespace-pre-wrap">
{renderOtherFields(payload)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</main>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getAuthMessages } from "@/features/i18n/messages/auth";
type PasswordStrengthLabel = "weak" | "medium" | "strong";
function getPasswordStrengthLabel(password: string): PasswordStrengthLabel | null {
if (!password || password.length < 4) {
return null;
}
let score = 0;
if (/[a-z]/.test(password)) score += 1;
if (/[A-Z]/.test(password)) score += 1;
if (/[0-9]/.test(password)) score += 1;
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
if (password.length >= 12) score += 1;
if (password.length < 8 || score <= 2) {
return "weak";
}
if (score === 3) {
return "medium";
}
return "strong";
}
export default function SignupPage() {
const router = useRouter();
const locale = useAppLocale();
const { signup: t } = getAuthMessages(locale);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = 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("/dashboard");
}
} catch {
// ignore
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (password !== passwordConfirm) {
setError(t.mismatchError);
return;
}
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 ?? t.signupFailedFallbackError);
} catch {
setError(t.signupFailedFallbackError);
}
setLoading(false);
return;
}
router.push("/dashboard");
} catch {
setError(t.errorNetwork);
} finally {
setLoading(false);
}
};
const strengthLabel = getPasswordStrengthLabel(password);
let strengthText: string | null = null;
if (strengthLabel) {
strengthText =
strengthLabel === "weak"
? t.passwordStrengthWeak
: strengthLabel === "medium"
? t.passwordStrengthMedium
: t.passwordStrengthStrong;
}
return (
<main className="min-h-screen flex items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900/70">
<h1 className="text-lg font-semibold mb-1">{t.title}</h1>
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400">{t.description}</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-800 dark:text-slate-200">
{t.emailLabel}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-800 dark:text-slate-200">
{t.passwordLabel}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
required
minLength={8}
/>
</div>
{strengthLabel && (
<div className="mt-1 space-y-1">
<div className="h-1.5 rounded-full bg-slate-200/70 dark:bg-slate-700/70 overflow-hidden">
<div
data-testid="password-strength-bar"
className={
"h-full transition-all " +
(strengthLabel === "weak"
? "w-1/3 bg-red-500"
: strengthLabel === "medium"
? "w-2/3 bg-amber-500"
: "w-full bg-emerald-600")
}
/>
</div>
<p
className={
"text-[11px] " +
(strengthLabel === "weak"
? "text-red-500 dark:text-red-300"
: strengthLabel === "medium"
? "text-amber-500 dark:text-amber-300"
: "text-emerald-600 dark:text-emerald-300")
}
>
{t.passwordStrengthPrefix} {strengthText}
</p>
</div>
)}
<div className="space-y-1">
<label htmlFor="passwordConfirm" className="block text-slate-800 dark:text-slate-200">
{t.passwordConfirmLabel}
</label>
<input
id="passwordConfirm"
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
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 ? t.submitLoading : t.submitIdle}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-500 dark:text-slate-400">
{t.loginPromptPrefix}
{" "}
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
{t.loginLinkText}
</Link>
{t.loginPromptSuffix && " "}
{t.loginPromptSuffix}
</p>
</div>
</main>
);
}
+146
View File
@@ -0,0 +1,146 @@
import bcrypt from "bcryptjs";
import jwt, { JwtPayload } from "jsonwebtoken";
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "crypto";
// 인증/보안 관련 공통 유틸리티 함수들을 제공한다.
// - 비밀번호 해시/검증 (bcrypt 기반)
// - JWT 액세스 토큰 발급/검증
// - 민감정보 JSON 암호화/복호화 (AES-256-GCM)
export interface AccessTokenPayload extends JwtPayload {
sub: string;
email: string;
tokenVersion: number;
}
export interface JwtUserLike {
id: string;
email: string;
tokenVersion: number;
}
// 내부에서 사용할 JWT 시크릿을 가져온다.
// - 설정되지 않은 경우에는 명확한 에러를 던져 조기에 문제를 발견한다.
function getJwtSecret(): string {
const secret = process.env.AUTH_JWT_SECRET;
if (!secret || secret.length === 0) {
throw new Error("AUTH_JWT_SECRET 환경변수가 설정되지 않았습니다.");
}
return secret;
}
// 암호화 키 원본 문자열로부터 32바이트 AES 키를 파생한다.
// - 키 문자열 길이에 관계없이 SHA-256 해시를 사용해 32바이트를 만든다.
function deriveEncryptionKey(): Buffer {
const raw = process.env.AUTH_ENCRYPTION_KEY;
if (!raw || raw.length === 0) {
throw new Error("AUTH_ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.");
}
return createHash("sha256").update(raw).digest();
}
// 최소 비밀번호 길이 (보안 요구사항: 8자 이상)
const MIN_PASSWORD_LENGTH = 8;
// 비밀번호를 bcrypt 해시로 변환한다.
export async function hashPassword(plain: string): Promise<string> {
if (typeof plain !== "string" || plain.length < MIN_PASSWORD_LENGTH) {
throw new Error("비밀번호는 최소 8자 이상이어야 합니다.");
}
// bcryptjs 기본 라운드 수(10)를 사용한다. 필요 시 환경에 맞게 조정 가능.
const saltRounds = 10;
return await bcrypt.hash(plain, saltRounds);
}
// 평문 비밀번호와 저장된 해시가 일치하는지 검증한다.
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
if (!hash) return false;
if (!plain || plain.length < MIN_PASSWORD_LENGTH) {
// 너무 짧은 비밀번호는 즉시 false 를 반환해 타이밍 공격을 줄인다.
return false;
}
return await bcrypt.compare(plain, hash);
}
// 유저 정보를 기반으로 JWT 액세스 토큰을 발급한다.
export async function signAccessToken(user: JwtUserLike): Promise<string> {
const secret = getJwtSecret();
const payload: AccessTokenPayload = {
sub: user.id,
email: user.email,
tokenVersion: user.tokenVersion,
};
// 만료 시간은 기본 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,5 +1,7 @@
import type { ChangeEvent } from "react";
import { useState } from "react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorColorPickerFieldMessages } from "@/features/i18n/messages/editorColorPickerField";
export type ColorPaletteItem = {
// 팔레트 식별자
@@ -32,7 +34,7 @@ export type ColorPickerFieldProps = {
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
// 기본/중립 계열
{ id: "default", label: "기본", color: "#e5e7eb" },
{ id: "default", label: "없음", color: "" },
// 투명
{ id: "transparent", label: "투명", color: "transparent" },
{ id: "muted", label: "연한", color: "#9ca3af" },
@@ -75,6 +77,9 @@ export function ColorPickerField({
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
const locale = useAppLocale();
const m = getEditorColorPickerFieldMessages(locale);
const colorInputValue = value && value.startsWith("#") ? value : "#ffffff";
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
@@ -101,26 +106,28 @@ export function ColorPickerField({
if (palette.length > 0) {
if (selectedPaletteId) {
selectedPalette = palette.find((item) => item.id === selectedPaletteId) ?? null;
} else if (value) {
} else {
// value 가 빈 문자열이어도 color 가 "" 인 팔레트 항목(예: "없음")을 선택된 상태로 인식할 수 있도록
// 항상 color 매칭으로 selectedPalette 를 계산한다.
selectedPalette = palette.find((item) => item.color === value) ?? null;
}
}
return (
<div className="flex flex-col gap-1 text-xs text-slate-400">
<div className="flex flex-col gap-1 text-xs text-slate-500 dark: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"
className="h-8 w-8 rounded border border-slate-300 bg-white p-0 dark:border-slate-700 dark:bg-slate-900"
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"
className="w-28 flex-none rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={ariaLabelHexInput}
placeholder="예: #ff0000"
placeholder={m.hexPlaceholder}
value={value}
onChange={handleHexInputChange}
/>
@@ -128,30 +135,32 @@ export function ColorPickerField({
<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"
className="w-28 rounded border border-slate-300 bg-white flex items-center justify-between px-2 py-1 text-left text-slate-900 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"
onClick={() => setOpen((prev) => !prev)}
>
<span className="flex items-center gap-1">
<span
className="inline-block h-3 w-3 rounded-full"
className="inline-block h-3 w-3 rounded-full border border-slate-300 dark:border-slate-700"
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
/>
<span className="truncate text-slate-200">
{selectedPalette?.label ?? "색상 팔레트"}
<span className="truncate text-slate-700 dark:text-slate-100">
{selectedPalette
? m.paletteLabels[selectedPalette.id] ?? selectedPalette.label
: m.dropdownLabelDefault}
</span>
</span>
<span className="text-slate-500 text-[10px]"></span>
<span className="text-slate-400 dark: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">
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-300 bg-white max-h-40 overflow-auto z-10 dark:border-slate-800 dark:bg-slate-950">
{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 ${
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-200 last:border-b-0 dark:border-slate-800 ${
selectedPaletteId === item.id
? "bg-sky-900/40 text-sky-100"
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
? "bg-sky-100 text-sky-900 dark:bg-sky-900/40 dark:text-sky-100"
: "bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
}`}
onClick={(event) => {
event.stopPropagation();
@@ -161,10 +170,10 @@ export function ColorPickerField({
>
<span className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
className="inline-block h-3 w-3 rounded-full border border-slate-300 dark:border-slate-700"
style={{ backgroundColor: item.color }}
/>
<span>{item.label}</span>
<span>{m.paletteLabels[item.id] ?? item.label}</span>
</span>
</button>
))}
@@ -60,7 +60,7 @@ export function NumericPropertyControl({
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>
{label}
{unitLabel ? ` ${unitLabel}` : ""}
@@ -68,7 +68,7 @@ export function NumericPropertyControl({
<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"
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={`${label} 프리셋`}
value={currentPresetId}
onChange={handlePresetChange}
@@ -84,7 +84,7 @@ export function NumericPropertyControl({
<PropertySliderField
label=""
ariaLabelSlider={`${label} 슬라이더`}
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
ariaLabelInput={`${label} 커스텀`}
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
@@ -45,7 +45,7 @@ export function PropertySliderField({
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
@@ -60,7 +60,7 @@ export function PropertySliderField({
/>
</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"
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
aria-label={ariaLabelInput}
value={Number.isFinite(value) ? value : ""}
onChange={handleInputChange}
File diff suppressed because it is too large Load Diff
+167 -65
View File
@@ -9,6 +9,9 @@ import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
import { getEditorBlockDefaultsMessages } from "@/features/i18n/messages/editorBlockDefaults";
import { DEFAULT_LOCALE, type AppLocale } from "@/features/i18n/locale";
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
export type BlockType =
@@ -116,6 +119,13 @@ export interface ButtonBlockProps {
strokeColorCustom?: string;
// 버튼 텍스트 색상 커스텀 값
textColorCustom?: string;
// 폼 컨트롤러에서 Submit 버튼 매핑 시 사용할 수 있는 전송 키 (name 역할)
formFieldName?: string;
imageSrc?: string;
imageAlt?: string;
imageSourceType?: "asset" | "externalUrl";
imageAssetId?: string | null;
imagePlacement?: "left" | "right" | "top" | "bottom";
}
// 이미지 블록 속성
@@ -552,6 +562,7 @@ export interface FormFieldStyleProps {
optionGapPx?: number;
labelGapPx?: number;
labelLayout?: "stacked" | "inline";
optionLayout?: "stacked" | "inline";
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 필드 텍스트 색상/타이포그라피
fontSizeCustom?: string;
@@ -573,6 +584,7 @@ export interface FormInputBlockProps extends FormFieldStyleProps {
inputType?: "text" | "email" | "textarea";
placeholder?: string;
labelMode?: FormLabelMode;
labelDisplay?: "visible" | "hidden" | "floating";
labelImageUrl?: string;
labelImageAlt?: string;
}
@@ -589,6 +601,7 @@ export interface FormSelectBlockProps extends FormFieldStyleProps {
options: FormSelectOption[];
required?: boolean;
labelMode?: FormLabelMode;
labelDisplay?: "visible" | "hidden";
labelImageUrl?: string;
labelImageAlt?: string;
}
@@ -608,6 +621,7 @@ export interface FormCheckboxBlockProps extends FormFieldStyleProps {
required?: boolean;
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
groupLabelMode?: FormLabelMode;
groupLabelDisplay?: "visible" | "hidden";
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
@@ -628,6 +642,7 @@ export interface FormRadioBlockProps extends FormFieldStyleProps {
required?: boolean;
// 라디오 그룹 타이틀의 텍스트/이미지 모드
groupLabelMode?: FormLabelMode;
groupLabelDisplay?: "visible" | "hidden";
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
@@ -643,7 +658,7 @@ export interface FormFieldConfig {
}
// 폼 블록 속성
export type FormSubmitTarget = "internal" | "webhook";
export type FormSubmitTarget = "internal" | "webhook" | "both";
export type FormPayloadFormat = "form" | "json";
export interface FormBlockProps {
@@ -664,6 +679,8 @@ export interface FormBlockProps {
fields?: FormFieldConfig[];
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
fieldIds?: string[];
// v2 컨트롤러: fieldIds 로 연결된 필드 중 어떤 필드를 필수로 처리할지 관리하는 ID 목록
requiredFieldIds?: string[];
submitButtonId?: string | null;
// 폼 레이아웃
formWidthMode?: "auto" | "full" | "fixed";
@@ -723,27 +740,27 @@ export interface EditorState {
future: Block[][];
projectConfig: ProjectConfig;
updateProjectConfig: (partial: Partial<ProjectConfig>) => void;
addTextBlock: () => void;
addButtonBlock: () => void;
addTextBlock: (locale?: AppLocale | null) => void;
addButtonBlock: (locale?: AppLocale | null) => void;
addImageBlock: () => void;
addVideoBlock: () => void;
addDividerBlock: () => void;
addListBlock: () => void;
addListBlock: (locale?: AppLocale | null) => void;
addSectionBlock: () => void;
addFormBlock: () => void;
addFormInputBlock: () => void;
addFormSelectBlock: () => void;
addFormCheckboxBlock: () => void;
addFormRadioBlock: () => void;
addHeroTemplateSection: () => void;
addFeaturesTemplateSection: () => void;
addCtaTemplateSection: () => void;
addFaqTemplateSection: () => void;
addPricingTemplateSection: () => void;
addTestimonialsTemplateSection: () => void;
addBlogTemplateSection: () => void;
addTeamTemplateSection: () => void;
addFooterTemplateSection: () => void;
addFormBlock: (locale?: AppLocale | null) => void;
addFormInputBlock: (locale?: AppLocale | null) => void;
addFormSelectBlock: (locale?: AppLocale | null) => void;
addFormCheckboxBlock: (locale?: AppLocale | null) => void;
addFormRadioBlock: (locale?: AppLocale | null) => void;
addHeroTemplateSection: (locale?: AppLocale | null) => void;
addFeaturesTemplateSection: (locale?: AppLocale | null) => void;
addCtaTemplateSection: (locale?: AppLocale | null) => void;
addFaqTemplateSection: (locale?: AppLocale | null) => void;
addPricingTemplateSection: (locale?: AppLocale | null) => void;
addTestimonialsTemplateSection: (locale?: AppLocale | null) => void;
addBlogTemplateSection: (locale?: AppLocale | null) => void;
addTeamTemplateSection: (locale?: AppLocale | null) => void;
addFooterTemplateSection: (locale?: AppLocale | null) => void;
selectListItem: (itemId: string | null) => void;
indentSelectedListItem: (blockId: string) => void;
outdentSelectedListItem: (blockId: string) => void;
@@ -768,12 +785,22 @@ export interface EditorState {
redo: () => void;
removeBlock: (id: string) => void;
duplicateBlock: (id: string) => void;
resetHistory: () => void;
}
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
let idCounter = 0;
const createId = () => `blk_${Date.now()}_${idCounter++}`;
// undo/redo 를 위한 히스토리 유틸리티: 상태 변경 전에 현재 blocks 스냅샷을 history 에 쌓고 future 는 비운다.
const pushHistoryImpl = (set: any, get: any) => {
const { blocks } = get() as EditorState;
set((state: EditorState) => ({
history: [...state.history, blocks],
future: [],
}));
};
const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
const regex = new RegExp(`^${prefix}-(\\d+)$`);
let max = 0;
@@ -793,16 +820,27 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
return `${prefix}-${max + 1}`;
};
const isKoLocale = (locale?: AppLocale | null): boolean => (locale ?? DEFAULT_LOCALE) === "ko";
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
const createEditorState = (set: any, get: any): EditorState => ({
const createEditorState = (set: any, get: any): EditorState => {
const pushHistory = () => {
const { blocks } = get() as EditorState;
set((state: EditorState) => ({
history: [...state.history, blocks],
future: [],
}));
};
return {
blocks: [],
selectedBlockId: null,
selectedListItemId: null,
history: [],
future: [],
projectConfig: {
title: "새 페이지",
title: "New page",
slug: "my-landing",
canvasPreset: "full",
canvasBgColorHex: "#020617",
@@ -827,7 +865,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
addTextBlock: () => {
addTextBlock: (locale?: AppLocale | null) => {
const id = createId();
const { selectedBlockId, blocks } = get();
@@ -848,11 +886,12 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const newBlock: Block = {
id,
type: "text",
props: {
text: "새 텍스트",
text: blockDefaults.text.defaultText,
align: "left",
size: "base",
},
@@ -860,16 +899,15 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
history: [...state.history, blocks],
future: [],
}));
},
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
addHeroTemplateSection: () => {
addHeroTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -882,11 +920,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
sectionId,
createId,
messages: tpl.hero,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -904,13 +945,8 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
// 리스트 아이템 선택 상태를 업데이트한다.
selectListItem: (itemId) => {
set({ selectedListItemId: itemId ?? null });
},
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
addFeaturesTemplateSection: () => {
addFeaturesTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -923,11 +959,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
sectionId,
createId,
messages: tpl.features,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -946,7 +985,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
addBlogTemplateSection: () => {
addBlogTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -959,11 +998,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
sectionId,
createId,
messages: tpl.blog,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -982,7 +1024,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
addTeamTemplateSection: () => {
addTeamTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -995,11 +1037,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
sectionId,
createId,
messages: tpl.team,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1018,7 +1063,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
addFooterTemplateSection: () => {
addFooterTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -1031,11 +1076,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
sectionId,
createId,
messages: tpl.footer,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1054,7 +1102,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
addCtaTemplateSection: () => {
addCtaTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -1067,11 +1115,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
sectionId,
createId,
messages: tpl.cta,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1090,7 +1141,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
addFaqTemplateSection: () => {
addFaqTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -1103,11 +1154,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
sectionId,
createId,
messages: tpl.faq,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1126,7 +1180,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
addPricingTemplateSection: () => {
addPricingTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -1139,11 +1193,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
sectionId,
createId,
messages: tpl.pricing,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1162,7 +1219,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
addTestimonialsTemplateSection: () => {
addTestimonialsTemplateSection: (locale?: AppLocale | null) => {
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
@@ -1175,11 +1232,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const tpl = getEditorTemplatesMessages(locale);
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
sectionId,
createId,
messages: tpl.testimonials,
});
pushHistoryImpl(set, get);
set((state: EditorState) => {
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
@@ -1224,7 +1284,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
type: "image",
props: {
src: "",
alt: "이미지 설명",
alt: "Image description",
align: "center",
widthMode: "auto",
borderRadius: "md",
@@ -1233,6 +1293,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1279,6 +1340,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1286,11 +1348,12 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
addFormInputBlock: () => {
addFormInputBlock: (locale?: AppLocale | null) => {
const id = createId();
const { blocks } = get();
const label = "입력 필드";
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const label = blockDefaults.formInput.label;
const formFieldName = getNextFormFieldName(blocks, "text");
const newBlock: Block = {
@@ -1318,6 +1381,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1325,15 +1389,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 폼 셀렉트 블록 추가: 루트에 기본 label/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
addFormSelectBlock: () => {
addFormSelectBlock: (locale?: AppLocale | null) => {
const id = createId();
const { blocks } = get();
const label = "선택 필드";
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const selectDefaults = blockDefaults.formSelect;
const label = selectDefaults.label;
const formFieldName = getNextFormFieldName(blocks, "select");
const options: FormSelectOption[] = [
{ label: "옵션 1", value: "option_1" },
{ label: "옵션 2", value: "option_2" },
{ label: selectDefaults.option1Label, value: "option_1" },
{ label: selectDefaults.option2Label, value: "option_2" },
];
const newBlock: Block = {
@@ -1359,6 +1425,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1366,15 +1433,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 폼 체크박스 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
addFormCheckboxBlock: () => {
addFormCheckboxBlock: (locale?: AppLocale | null) => {
const id = createId();
const { blocks } = get();
const groupLabel = "체크박스";
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const checkboxDefaults = blockDefaults.formCheckbox;
const groupLabel = checkboxDefaults.groupLabel;
const formFieldName = getNextFormFieldName(blocks, "checkbox");
const options: FormCheckboxOption[] = [
{ label: "체크 1", value: "check_1" },
{ label: "체크 2", value: "check_2" },
{ label: checkboxDefaults.option1Label, value: "check_1" },
{ label: checkboxDefaults.option2Label, value: "check_2" },
];
const newBlock: Block = {
@@ -1401,6 +1470,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1408,15 +1478,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 폼 라디오 그룹 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
addFormRadioBlock: () => {
addFormRadioBlock: (locale?: AppLocale | null) => {
const id = createId();
const { blocks } = get();
const groupLabel = "라디오 그룹";
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const radioDefaults = blockDefaults.formRadio;
const groupLabel = radioDefaults.groupLabel;
const formFieldName = getNextFormFieldName(blocks, "radio");
const options: FormRadioOption[] = [
{ label: "옵션 A", value: "option_a" },
{ label: "옵션 B", value: "option_b" },
{ label: radioDefaults.optionALabel, value: "option_a" },
{ label: radioDefaults.optionBLabel, value: "option_b" },
];
const newBlock: Block = {
@@ -1443,6 +1515,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1483,6 +1556,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1490,7 +1564,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 리스트 블록 추가: 기본 항목/정렬과 함께 생성 후 선택 상태로 만든다
addListBlock: () => {
addListBlock: (locale?: AppLocale | null) => {
const id = createId();
const { selectedBlockId, blocks } = get();
@@ -1511,15 +1585,18 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const itemText = blockDefaults.list.firstItemText;
const newBlock: Block = {
id,
type: "list",
props: {
items: ["리스트 아이템 1"],
items: [itemText],
itemsTree: [
{
id: `${id}_item_1`,
text: "리스트 아이템 1",
text: itemText,
children: [],
},
],
@@ -1530,6 +1607,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1556,6 +1634,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1563,28 +1642,31 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 폼 블록 추가: 기본 contact 폼 설정과 함께 생성 후 선택 상태로 만든다
addFormBlock: () => {
addFormBlock: (locale?: AppLocale | null) => {
const id = createId();
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const formDefaults = blockDefaults.form;
const fields: FormFieldConfig[] = [
{
id: `${id}_field_name`,
name: "name",
label: "이름",
label: formDefaults.nameLabel,
type: "text",
required: true,
},
{
id: `${id}_field_email`,
name: "email",
label: "이메일",
label: formDefaults.emailLabel,
type: "email",
required: true,
},
{
id: `${id}_field_message`,
name: "message",
label: "메시지",
label: formDefaults.messageLabel,
type: "textarea",
required: true,
},
@@ -1596,8 +1678,8 @@ const createEditorState = (set: any, get: any): EditorState => ({
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "성공적으로 전송되었습니다.",
errorMessage: "전송 중 오류가 발생했습니다.",
successMessage: formDefaults.successMessage,
errorMessage: formDefaults.errorMessage,
fields,
fieldIds: [],
submitButtonId: null,
@@ -1606,6 +1688,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId: null,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1613,7 +1696,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
addButtonBlock: () => {
addButtonBlock: (locale?: AppLocale | null) => {
const id = createId();
const { selectedBlockId, blocks } = get();
@@ -1633,11 +1716,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
}
}
}
const blockDefaults = getEditorBlockDefaultsMessages(locale);
const newBlock: Block = {
id,
type: "button",
props: {
label: "버튼",
label: blockDefaults.button.label,
href: "#",
align: "left",
size: "md",
@@ -1660,6 +1746,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
columnId,
};
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
@@ -1668,6 +1755,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
updateBlock: (id, partial) => {
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: state.blocks.map((block: Block) =>
block.id === id
@@ -1680,6 +1768,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
// 현재 선택된 리스트 아이템 ID 를 상태에 저장한다.
selectListItem: (itemId) => {
set({ selectedListItemId: itemId });
},
// 선택된 리스트 아이템을 들여쓰기한다.
indentSelectedListItem: (blockId) => {
const { blocks, selectedListItemId } = get();
@@ -1816,6 +1909,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
replaceBlocks: (blocks) => {
pushHistoryImpl(set, get);
set({
blocks,
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
@@ -1823,6 +1917,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
},
reorderBlocks: (activeId, overId) => {
pushHistoryImpl(set, get);
set((state: EditorState) => {
const current = state.blocks;
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
@@ -1840,6 +1935,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
moveBlock: (id, sectionId, columnId) => {
pushHistoryImpl(set, get);
set((state: EditorState) => ({
blocks: state.blocks.map((block: Block) =>
block.id === id
@@ -1853,6 +1949,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
removeBlock: (id) => {
pushHistoryImpl(set, get);
set((state: EditorState) => {
const current = state.blocks;
const index = current.findIndex((b) => b.id === id);
@@ -1884,6 +1981,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
duplicateBlock: (id) => {
pushHistoryImpl(set, get);
set((state: EditorState) => {
const current = state.blocks;
const index = current.findIndex((b) => b.id === id);
@@ -1939,7 +2037,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
future: nextFuture,
}));
},
});
resetHistory: () => {
set({ history: [], future: [] });
},
};
};
// React 컴포넌트에서 사용하는 전역 훅 스토어
export const useEditorStore = create<EditorState>()(createEditorState);
+48 -7
View File
@@ -18,6 +18,9 @@ export interface ButtonStyleInput {
widthPx?: number | null | undefined;
paddingX?: number | null | undefined;
paddingY?: number | null | undefined;
fontSizeCustom?: string | null | undefined;
lineHeightCustom?: string | null | undefined;
letterSpacingCustom?: string | null | undefined;
fillColorCustom?: string | null | undefined;
strokeColorCustom?: string | null | undefined;
textColorCustom?: string | null | undefined;
@@ -64,17 +67,17 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
inlineStyles.push(`width:${input.widthPx}px`);
inlineStyles.push(`width:${pxToEm(input.widthPx)}`);
}
if (typeof input.paddingX === "number") {
inlineStyles.push(`padding-left:${input.paddingX}px`);
inlineStyles.push(`padding-right:${input.paddingX}px`);
inlineStyles.push(`padding-left:${pxToEm(input.paddingX)}`);
inlineStyles.push(`padding-right:${pxToEm(input.paddingX)}`);
}
if (typeof input.paddingY === "number") {
inlineStyles.push(`padding-top:${input.paddingY}px`);
inlineStyles.push(`padding-bottom:${input.paddingY}px`);
inlineStyles.push(`padding-top:${pxToEm(input.paddingY)}`);
inlineStyles.push(`padding-bottom:${pxToEm(input.paddingY)}`);
}
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
@@ -89,6 +92,42 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
}
if (input.fontSizeCustom && input.fontSizeCustom.trim() !== "") {
const fontSizeValue = input.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
inlineStyles.push(`font-size:${fontSizeEm}`);
}
} else {
inlineStyles.push(`font-size:${fontSizeValue}`);
}
}
if (input.lineHeightCustom && input.lineHeightCustom.trim() !== "") {
const lineHeightValue = input.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
inlineStyles.push(`line-height:${lineHeightEm}`);
}
} else {
inlineStyles.push(`line-height:${lineHeightValue}`);
}
}
if (input.letterSpacingCustom && input.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = input.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
inlineStyles.push(`letter-spacing:${letterSpacingEm}`);
}
} else {
inlineStyles.push(`letter-spacing:${letterSpacingValue}`);
}
}
return {
alignClass,
sizeClass,
@@ -153,11 +192,13 @@ export function computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublic
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
styleOverrides.paddingInline = pxToEm(props.paddingX);
const paddingEm = pxToEm(props.paddingX);
styleOverrides.paddingInline = paddingEm;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
styleOverrides.paddingBlock = pxToEm(props.paddingY);
const paddingEm = pxToEm(props.paddingY);
styleOverrides.paddingBlock = paddingEm;
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
+28 -1
View File
@@ -19,8 +19,35 @@ export const computeDividerExportTokens = (
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
const color = colorRaw !== "" ? colorRaw : "#475569";
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
const marginEm = pxToEm(marginY);
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginY}px 0;`;
const widthMode = props.widthMode ?? "full";
const align = props.align ?? "left";
const styleParts: string[] = [];
styleParts.push("border:0");
styleParts.push(`border-bottom:${thickness} solid ${escapeAttr(color)}`);
styleParts.push(`margin:${marginEm} 0`);
if (widthMode === "auto") {
styleParts.push("width:50%");
} else if (widthMode === "fixed") {
const widthPx =
typeof props.widthPx === "number" && props.widthPx > 0 ? props.widthPx : 320;
styleParts.push(`width:${pxToEm(widthPx)}`);
}
if (widthMode === "auto" || widthMode === "fixed") {
if (align === "center") {
styleParts.push("margin-left:auto");
styleParts.push("margin-right:auto");
} else if (align === "right") {
styleParts.push("margin-left:auto");
styleParts.push("margin-right:0");
}
}
const style = `${styleParts.join(";")};`;
return { style };
};
+215 -76
View File
@@ -42,6 +42,12 @@ const normalizeTextColor = (raw: unknown): string => {
return trimmed !== "" ? trimmed : "";
};
// Export/Public 레이어용 모서리 둥글기 매핑
// - none: 0px
// - sm: 2px
// - lg: 6px
// - full: 9999px (완전 둥근 모서리)
// - md(기본): 4px
const computeRadiusPx = (radius: unknown): number => {
const token = typeof radius === "string" ? radius : "md";
if (token === "none") return 0;
@@ -51,6 +57,22 @@ const computeRadiusPx = (radius: unknown): number => {
return 4;
};
// Editor 레이어용 모서리 둥글기 매핑
// Export/Public 과 동일한 스케일을 사용하되, full 에 대해서만 pill 스타일(9999px)을 유지한다.
// - none: 0px
// - sm: 2px
// - md: 4px (기본)
// - lg: 6px
// - full: 9999px
const computeEditorRadiusPx = (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";
@@ -84,25 +106,85 @@ export const computeFormInputExportTokens = (
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
inputStyleParts.push(`padding-left:${px}px`);
inputStyleParts.push(`padding-right:${px}px`);
const em = props.paddingX / 16;
inputStyleParts.push(`padding-left:${em}em`);
inputStyleParts.push(`padding-right:${em}em`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
inputStyleParts.push(`padding-top:${py}px`);
inputStyleParts.push(`padding-bottom:${py}px`);
const em = props.paddingY / 16;
inputStyleParts.push(`padding-top:${em}em`);
inputStyleParts.push(`padding-bottom:${em}em`);
}
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
inputStyleParts.push(`width:${props.widthPx}px`);
const em = props.widthPx / 16;
inputStyleParts.push(`width:${em}em`);
}
const radiusPx = computeRadiusPx(props.borderRadius);
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
let radiusPx = 4;
if (radiusToken === "none") {
radiusPx = 0;
} else if (radiusToken === "sm") {
radiusPx = 2;
} else if (radiusToken === "lg") {
radiusPx = 6;
} else if (radiusToken === "full") {
radiusPx = 9999;
}
inputStyleParts.push(`border-radius:${radiusPx}px`);
// 폰트 관련 커스텀 값: px 단위일 경우 em 으로 변환하고, 그렇지 않으면 원문 값을 그대로 사용한다.
if (typeof props.fontSizeCustom === "string" && props.fontSizeCustom.trim() !== "") {
const raw = props.fontSizeCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`font-size:${em}em`);
}
}
} else {
inputStyleParts.push(`font-size:${escapeAttr(raw)}`);
}
}
if (typeof props.lineHeightCustom === "string" && props.lineHeightCustom.trim() !== "") {
const raw = props.lineHeightCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`line-height:${em}em`);
}
}
} else {
inputStyleParts.push(`line-height:${escapeAttr(raw)}`);
}
}
if (typeof props.letterSpacingCustom === "string" && props.letterSpacingCustom.trim() !== "") {
const raw = props.letterSpacingCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`letter-spacing:${em}em`);
}
}
} else {
inputStyleParts.push(`letter-spacing:${escapeAttr(raw)}`);
}
}
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
return {
@@ -127,19 +209,20 @@ export const computeFormSelectExportTokens = (
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
selectStyleParts.push(`width:${props.widthPx}px`);
const em = props.widthPx / 16;
selectStyleParts.push(`width:${em}em`);
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
selectStyleParts.push(`padding-left:${px}px`);
selectStyleParts.push(`padding-right:${px}px`);
const em = props.paddingX / 16;
selectStyleParts.push(`padding-left:${em}em`);
selectStyleParts.push(`padding-right:${em}em`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
selectStyleParts.push(`padding-top:${py}px`);
selectStyleParts.push(`padding-bottom:${py}px`);
const em = props.paddingY / 16;
selectStyleParts.push(`padding-top:${em}em`);
selectStyleParts.push(`padding-bottom:${em}em`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
@@ -176,15 +259,15 @@ const computeOptionGroupExportTokensBase = (
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
optionStyleParts.push(`padding-left:${px}px`);
optionStyleParts.push(`padding-right:${px}px`);
const em = props.paddingX / 16;
optionStyleParts.push(`padding-left:${em}em`);
optionStyleParts.push(`padding-right:${em}em`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
optionStyleParts.push(`padding-top:${py}px`);
optionStyleParts.push(`padding-bottom:${py}px`);
const em = props.paddingY / 16;
optionStyleParts.push(`padding-top:${em}em`);
optionStyleParts.push(`padding-bottom:${em}em`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
@@ -252,13 +335,29 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
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;
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const radiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = radiusPx;
// 에디터에서는 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";
@@ -313,13 +412,40 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
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;
// 모서리 둥글기: formSelect 는 Export/Public 레이어와 동일한 스케일(0/2/4/6/9999px)을 사용해
// 에디터/프리뷰/퍼블릭 간 border-radius 가 일치하도록 한다.
const selectRadiusToken = props.borderRadius ?? "md";
const selectRadiusPx =
selectRadiusToken === "none"
? 0
: selectRadiusToken === "sm"
? 2
: selectRadiusToken === "lg"
? 6
: selectRadiusToken === "full"
? 9999
: 4;
fieldStyle.borderRadius = selectRadiusPx;
// 체크박스/라디오 그룹도 에디터에서 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 };
@@ -345,13 +471,28 @@ export const computeFormOptionGroupEditorTokens = (
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;
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const groupRadiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = groupRadiusPx;
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 };
@@ -471,23 +612,21 @@ export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormIn
}
}
// 색상 관련 커스텀 값
// 색상 관련 커스텀 값: builder.css 의 pb-input 기본 색상을 덮어쓰지 않도록,
// 사용자가 커스텀 값을 설정했을 때에만 인라인 스타일을 적용한다.
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";
const borderColor = props.strokeColorCustom.trim();
inputStyle.borderColor = borderColor;
// 플로팅 라벨 패치가 필드 테두리 색을 참조할 수 있도록 CSS 변수에 전달한다.
(wrapperStyle as any)["--pb-input-border-color"] = borderColor;
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -565,14 +704,12 @@ export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): Form
}
}
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고,
// 없으면 builder.css 의 pb-select 기본 색상을 그대로 사용한다.
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 에 적용한다.
@@ -630,6 +767,7 @@ const computeOptionGroupPublicTokensBase = (
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
// 퍼블릭 토큰에서는 고정 너비를 em 단위로 변환해 px 단위를 피한다.
groupStyle.width = pxToEm(props.widthPx);
}
@@ -679,9 +817,6 @@ const computeOptionGroupPublicTokensBase = (
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) {
@@ -719,7 +854,12 @@ const computeOptionGroupPublicTokensBase = (
optionContainerStyle.borderRadius = `${radiusPx}px`;
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
const gapPx = props.optionGapPx;
const gapEm = pxToEm(gapPx);
optionsStyle.rowGap = gapEm;
if ((props as any).optionLayout === "inline") {
optionsStyle.columnGap = gapEm;
}
}
return {
@@ -780,6 +920,10 @@ export const computeFormControllerPublicTokens = (
.map((fieldBlock) => {
const anyProps: any = fieldBlock.props ?? {};
const isRequired = Array.isArray(props.requiredFieldIds)
? props.requiredFieldIds.includes(fieldBlock.id)
: false;
if (fieldBlock.type === "formInput") {
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
@@ -790,7 +934,7 @@ export const computeFormControllerPublicTokens = (
label,
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
type: "text",
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -805,7 +949,7 @@ export const computeFormControllerPublicTokens = (
label,
type: "select",
options,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -822,7 +966,7 @@ export const computeFormControllerPublicTokens = (
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -838,34 +982,29 @@ export const computeFormControllerPublicTokens = (
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
required: isRequired,
} 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,
}))
: [];
// 프리뷰/퍼블릭 렌더러에서는 FormBlock 의 v1 fields 설정을 사용하지 않고,
// fieldIds 로 연결된 실제 폼 필드 블록들만 기반으로 폼 필드를 렌더링한다.
const fields: FormControllerFieldPublicConfig[] = controllerFields;
const mappedSubmitButton = blocks.find(
(b) => b.type === "button" && b.id === props.submitButtonId,
);
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이므로
// formClassName 은 고정 기본값만 사용하고, formStyle 은 항상 빈 객체로 유지한다.
// 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(" "),
+2 -1
View File
@@ -27,7 +27,8 @@ export function computeImageExportStyles(input: ImageExportStyleInput): ImageExp
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
imgStyleParts.push(`width:${input.widthPx}px`);
const widthEm = input.widthPx / 16;
imgStyleParts.push(`width:${widthEm}em`);
}
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
+40 -2
View File
@@ -5,6 +5,7 @@ export interface ListExportTokens {
Tag: "ul" | "ol";
items: string[];
listStyleParts: string[];
gapEm: number;
}
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
@@ -37,6 +38,26 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
const listStyleParts: string[] = [`text-align:${align}`];
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
const bulletStyle = bulletStyleRaw === "none" ? "none" : bulletStyleRaw;
listStyleParts.push(`list-style-type:${bulletStyle}`);
const gapPx =
typeof props.gapYPx === "number"
? props.gapYPx
: props.gapY === "sm"
? 4
: props.gapY === "lg"
? 16
: 8;
const gapEm = gapPx / 16;
// 리스트 아이템 간 간격은 CSS 커스텀 프로퍼티 --pb-list-gap 으로 전달한다.
// 이렇게 하면 margin-bottom 계산은 builder.css 레이어에서만 수행되어,
// 정적 Export HTML 의 style 속성에 직접적인 margin-bottom 이 포함되지 않는다.
listStyleParts.push(`--pb-list-gap:${gapEm}em`);
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
}
@@ -45,6 +66,7 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
Tag,
items,
listStyleParts,
gapEm,
};
};
@@ -106,7 +128,11 @@ const convertPxStringToEm = (value?: string | null) => {
export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
const alignClass =
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
props.align === "center"
? "pb-text-center"
: props.align === "right"
? "pb-text-right"
: "pb-text-left";
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
@@ -134,7 +160,15 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
listStyle.lineHeight = props.lineHeightCustom;
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
listStyle.lineHeight = lineHeightEm;
}
} else {
listStyle.lineHeight = lineHeightValue;
}
}
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
listStyle.color = props.textColorCustom;
@@ -143,6 +177,10 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens
listStyle.backgroundColor = props.backgroundColorCustom.trim();
}
// 리스트 아이템 간 여백을 CSS 변수로 노출한다. 실제 margin-bottom 은 builder.css 의
// .pb-list > li 규칙에서 --pb-list-gap 값을 참조해 계산한다.
(listStyle as any)["--pb-list-gap"] = `${gapEm}em`;
const bulletStyle = bulletStyleRaw;
return {
+22 -2
View File
@@ -1,9 +1,13 @@
import type { CSSProperties } from "react";
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
const pxToEm = (px: number, base = 16) => `${px / base}em`;
export interface SectionExportTokens {
sectionClasses: string;
sectionStyleParts: string[];
innerWrapperStyleParts: string[];
columnsStyleParts: string[];
backgroundVideoHtml: string;
}
@@ -26,6 +30,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
const sectionStyleParts: string[] = [];
const innerWrapperStyleParts: string[] = [];
const columnsStyleParts: string[] = [];
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
@@ -65,6 +71,20 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
sectionStyleParts.push(`background-repeat:${repeat}`);
}
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
const paddingEm = pxToEm(props.paddingYPx);
sectionStyleParts.push(`padding-top:${paddingEm}`);
sectionStyleParts.push(`padding-bottom:${paddingEm}`);
}
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
innerWrapperStyleParts.push(`max-width:${pxToEm(props.maxWidthPx)}`);
}
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
columnsStyleParts.push(`column-gap:${pxToEm(props.gapXPx)}`);
}
const bgVideoSrc = props.backgroundVideoSrc;
let backgroundVideoHtml = "";
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
@@ -77,6 +97,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
return {
sectionClasses,
sectionStyleParts,
innerWrapperStyleParts,
columnsStyleParts,
backgroundVideoHtml,
};
}
@@ -89,8 +111,6 @@ export interface SectionPublicTokens {
backgroundVideoSrc: string | null;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
const sectionStyle: CSSProperties = {};
+51 -7
View File
@@ -58,8 +58,10 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
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 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",
@@ -71,10 +73,9 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
"3xl": "pb-text-3xl",
};
const sizeClass =
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
? fontSizeMap[fontSizeScale]
: "";
const sizeClass = fontSizeScale && fontSizeMap[fontSizeScale]
? fontSizeMap[fontSizeScale]
: "";
const lineHeightMode = input.lineHeightMode ?? "scale";
const lineHeightScale: TextLineHeightScale = (input.lineHeightScale as TextLineHeightScale | null) ?? "normal";
@@ -276,6 +277,17 @@ export interface TextPublicTokens {
styleOverrides: CSSProperties;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
const convertPxStringToEm = (value?: string | null) => {
if (!value) return null;
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const px = parseFloat(match[0]);
if (!Number.isFinite(px)) return null;
return pxToEm(px);
};
export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens {
const alignClass =
props.align === "center"
@@ -308,7 +320,15 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
props.fontSizeCustom &&
props.fontSizeCustom.trim() !== ""
) {
styleOverrides.fontSize = props.fontSizeCustom;
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
styleOverrides.fontSize = fontSizeEm;
}
} else {
styleOverrides.fontSize = fontSizeValue;
}
}
if (props.colorCustom && props.colorCustom.trim() !== "") {
@@ -319,6 +339,30 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
styleOverrides.lineHeight = lineHeightEm;
}
} else {
styleOverrides.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
styleOverrides.letterSpacing = letterSpacingEm;
}
} else {
styleOverrides.letterSpacing = letterSpacingValue;
}
}
return {
alignClass,
sizeClass,
+5 -4
View File
@@ -101,8 +101,9 @@ export function computeVideoExportTokens(
const widthMode = props.widthMode ?? "auto";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
wrapperStyleParts.push(`width:${props.widthPx}px`);
videoStyleParts.push(`width:${props.widthPx}px`);
const widthEm = pxToEm(props.widthPx);
wrapperStyleParts.push(`width:${widthEm}`);
videoStyleParts.push(`width:${widthEm}`);
} else if (widthMode === "full") {
wrapperStyleParts.push("width:100%");
videoStyleParts.push("width:100%");
@@ -113,11 +114,11 @@ export function computeVideoExportTokens(
}
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
wrapperStyleParts.push(`padding:${props.cardPaddingPx}px`);
wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`);
}
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
wrapperStyleParts.push(`border-radius:${props.borderRadiusPx}px`);
wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`);
}
const align =
+1
View File
@@ -0,0 +1 @@
export { buildStaticHtml } from "@/app/api/export/route";
+39
View File
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import type { AppLocale } from "./locale";
import { DEFAULT_LOCALE } from "./locale";
type LocaleContextValue = {
locale: AppLocale;
setLocale: (locale: AppLocale) => void;
};
const LocaleContext = createContext<LocaleContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {
// no-op default
},
});
export function LocaleProvider({
initialLocale,
children,
}: {
initialLocale: AppLocale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<AppLocale>(initialLocale);
return <LocaleContext.Provider value={{ locale, setLocale }}>{children}</LocaleContext.Provider>;
}
export function useAppLocale(): AppLocale {
return useContext(LocaleContext).locale;
}
export function useLocaleActions() {
const { setLocale } = useContext(LocaleContext);
return { setLocale };
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useAppLocale, useLocaleActions } from "./LocaleProvider";
import type { AppLocale } from "./locale";
export function LocaleSwitcher() {
const locale = useAppLocale();
const { setLocale } = useLocaleActions();
const handleToggle = () => {
const nextLocale: AppLocale = locale === "en" ? "ko" : "en";
setLocale(nextLocale);
try {
const maxAgeSeconds = 60 * 60 * 24 * 365; // 1 year
document.cookie = `pb-locale=${nextLocale}; path=/; max-age=${maxAgeSeconds}`;
} catch {
// 쿠키 저장 실패는 무시 (로케일 상태는 여전히 컨텍스트에서 유지된다)
}
};
const labelText = locale === "en" ? "EN" : "KO";
return (
<div className="fixed bottom-4 right-4 z-50 text-xs">
<button
type="button"
aria-label="Toggle locale"
className="inline-flex items-center justify-center rounded-full border border-slate-300 bg-white/80 px-2 py-1 text-slate-700 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/80 dark:text-slate-100"
onClick={handleToggle}
>
{labelText}
</button>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
export const SUPPORTED_LOCALES = ["en", "ko"] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
// 기본 로케일은 항상 en
export const DEFAULT_LOCALE: AppLocale = "en";
// Accept-Language 헤더 문자열에서 앱 로케일(en/ko)을 결정한다.
// - ko 가 포함되어 있으면 ko
// - 그렇지 않으면 기본(en)
export function resolveLocaleFromAcceptLanguage(header: string | null | undefined): AppLocale {
if (!header) {
return DEFAULT_LOCALE;
}
const value = header.toLowerCase();
if (value.includes("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
+120
View File
@@ -0,0 +1,120 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type LoginMessages = {
title: string;
description: string;
emailLabel: string;
passwordLabel: string;
submitIdle: string;
submitLoading: string;
themeToggleLabel: string;
errorLoginFailed: string;
errorNetwork: string;
signupPromptPrefix: string;
signupLinkText: string;
signupPromptSuffix: string;
};
export type SignupMessages = {
title: string;
description: string;
emailLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
submitIdle: string;
submitLoading: string;
mismatchError: string;
signupFailedFallbackError: string;
errorNetwork: string;
passwordStrengthPrefix: string;
passwordStrengthWeak: string;
passwordStrengthMedium: string;
passwordStrengthStrong: string;
loginPromptPrefix: string;
loginLinkText: string;
loginPromptSuffix: string;
};
export type AuthMessages = {
login: LoginMessages;
signup: SignupMessages;
};
const AUTH_MESSAGES: Record<AppLocale, AuthMessages> = {
en: {
login: {
title: "Log in",
description: "Sign in to manage and save your projects.",
emailLabel: "Email",
passwordLabel: "Password",
submitIdle: "Log in",
submitLoading: "Logging in...",
themeToggleLabel: "Toggle theme",
errorLoginFailed: "Failed to log in. Please try again.",
errorNetwork: "A network error occurred. Please try again later.",
signupPromptPrefix: "Don't have an account yet?",
signupLinkText: "Sign up",
signupPromptSuffix: "",
},
signup: {
title: "Sign up",
description: "Create a new account to save and manage your projects.",
emailLabel: "Email",
passwordLabel: "Password",
passwordConfirmLabel: "Confirm password",
submitIdle: "Sign up",
submitLoading: "Signing up...",
mismatchError: "Password and confirmation do not match.",
signupFailedFallbackError: "Failed to sign up. Please try again.",
errorNetwork: "A network error occurred. Please try again later.",
passwordStrengthPrefix: "Password strength:",
passwordStrengthWeak: "Weak",
passwordStrengthMedium: "Medium",
passwordStrengthStrong: "Strong",
loginPromptPrefix: "Already have an account?",
loginLinkText: "Log in",
loginPromptSuffix: "",
},
},
ko: {
login: {
title: "로그인",
description: "프로젝트를 관리하려면 먼저 계정으로 로그인하세요.",
emailLabel: "이메일",
passwordLabel: "비밀번호",
submitIdle: "로그인",
submitLoading: "로그인 중...",
themeToggleLabel: "테마 전환",
errorLoginFailed: "로그인에 실패했습니다. 다시 시도해 주세요.",
errorNetwork: "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
signupPromptPrefix: "아직 계정이 없다면",
signupLinkText: "회원가입",
signupPromptSuffix: "을 진행해 주세요.",
},
signup: {
title: "회원가입",
description: "새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.",
emailLabel: "이메일",
passwordLabel: "비밀번호",
passwordConfirmLabel: "비밀번호 확인",
submitIdle: "회원가입",
submitLoading: "회원가입 중...",
mismatchError: "비밀번호와 비밀번호 확인이 일치하지 않습니다.",
signupFailedFallbackError: "회원가입에 실패했습니다. 다시 시도해 주세요.",
errorNetwork: "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
passwordStrengthPrefix: "비밀번호 난이도:",
passwordStrengthWeak: "약함",
passwordStrengthMedium: "보통",
passwordStrengthStrong: "강함",
loginPromptPrefix: "이미 계정이 있다면",
loginLinkText: "로그인",
loginPromptSuffix: "으로 이동해 주세요.",
},
},
};
export function getAuthMessages(locale: AppLocale | null | undefined): AuthMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return AUTH_MESSAGES[key] ?? AUTH_MESSAGES[DEFAULT_LOCALE];
}
+103
View File
@@ -0,0 +1,103 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
// 대시보드/프로젝트 목록/전체 제출 내역 상단 GNB 및 헤더용 i18n 메시지
// - 로그인/회원가입과 동일하게 en(기본), ko 를 지원한다.
export type DashboardMessages = {
// 메인 대시보드 헤더 제목/설명
title: string;
description: string;
// 상단 GNB 탭 레이블
navDashboard: string;
navProjects: string;
navSubmissions: string;
// 헤더 오른쪽 액션 버튼/메뉴
themeToggleLabel: string;
menuLabel: string;
logoutLabel: string;
summaryTotalProjectsLabel: string;
summaryTotalSubmissionsLabel: string;
summaryTodaySubmissionsLabel: string;
summaryLast7DaysSubmissionsLabel: string;
chartTitle: string;
chartSubtitle: string;
chartEmptyLabel: string;
sectionProjectsTitle: string;
sectionProjectsEmpty: string;
projectTotalSubmissionsPrefix: string;
projectTotalSubmissionsSuffix: string;
projectLatestSubmissionPrefix: string;
projectLatestSubmissionEmpty: string;
projectViewSubmissionsLink: string;
projectOpenPublicPageLink: string;
errorUnauthorized: string;
errorGeneric: string;
loadingText: string;
};
const DASHBOARD_MESSAGES: Record<AppLocale, DashboardMessages> = {
en: {
title: "Dashboard",
description: "A quick overview of your projects and form submissions.",
navDashboard: "Dashboard",
navProjects: "Projects",
navSubmissions: "All submissions",
themeToggleLabel: "Toggle theme",
menuLabel: "Menu",
logoutLabel: "Log out",
summaryTotalProjectsLabel: "Projects",
summaryTotalSubmissionsLabel: "Total form submissions",
summaryTodaySubmissionsLabel: "Submissions today",
summaryLast7DaysSubmissionsLabel: "Submissions in last 7 days",
chartTitle: "Recent submissions (daily)",
chartSubtitle: "Only dates with recent activity are shown.",
chartEmptyLabel: "No daily submission data yet.",
sectionProjectsTitle: "Submissions by project",
sectionProjectsEmpty: "No projects created yet or no submissions have been received.",
projectTotalSubmissionsPrefix: "Total ",
projectTotalSubmissionsSuffix: " submissions",
projectLatestSubmissionPrefix: "Last submission:",
projectLatestSubmissionEmpty: "No submissions yet",
projectViewSubmissionsLink: "View form submissions",
projectOpenPublicPageLink: "Open public page",
errorUnauthorized: "You need to be signed in to view the dashboard. Please log in again.",
errorGeneric: "An error occurred while loading the dashboard. Please try again later.",
loadingText: "Loading dashboard data...",
},
ko: {
title: "대시보드",
description: "프로젝트와 폼 제출 현황을 한눈에 확인할 수 있는 요약 화면입니다.",
navDashboard: "대시보드",
navProjects: "프로젝트 목록",
navSubmissions: "전체 제출 내역",
themeToggleLabel: "테마 전환",
menuLabel: "메뉴",
logoutLabel: "로그아웃",
summaryTotalProjectsLabel: "프로젝트 수",
summaryTotalSubmissionsLabel: "전체 폼 제출 수",
summaryTodaySubmissionsLabel: "오늘 제출 수",
summaryLast7DaysSubmissionsLabel: "최근 7일 제출 수",
chartTitle: "최근 제출 추이 (일별)",
chartSubtitle: "최근 활동이 있는 날짜만 표시됩니다.",
chartEmptyLabel: "아직 일별 제출 내역이 없습니다.",
sectionProjectsTitle: "프로젝트별 제출 현황",
sectionProjectsEmpty: "아직 생성된 프로젝트가 없거나 제출 내역이 없습니다.",
projectTotalSubmissionsPrefix: "총 제출 ",
projectTotalSubmissionsSuffix: "건",
projectLatestSubmissionPrefix: "최근 제출:",
projectLatestSubmissionEmpty: "제출 내역 없음",
projectViewSubmissionsLink: "폼 제출 내역 보기",
projectOpenPublicPageLink: "퍼블릭 페이지 열기",
errorUnauthorized:
"대시보드를 보려면 로그인이 필요합니다. 다시 로그인해 주세요.",
errorGeneric:
"대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
loadingText: "대시보드 데이터를 불러오는 중입니다...",
},
};
export function getDashboardMessages(locale: AppLocale | null | undefined): DashboardMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return DASHBOARD_MESSAGES[key] ?? DASHBOARD_MESSAGES[DEFAULT_LOCALE];
}
+164
View File
@@ -0,0 +1,164 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorMessages = {
headerTitle: string;
headerDescription: string;
navProjects: string;
navPreview: string;
undoLabel: string;
redoLabel: string;
menuLabel: string;
menuProjectSaveLoad: string;
menuJsonImportExport: string;
menuExportZip: string;
menuClearCanvas: string;
menuDeleteProject: string;
confirmClearCanvas: string;
confirmDeleteProject: string;
modalProjectTitle: string;
modalProjectTitleLabel: string;
modalProjectSlugLabel: string;
modalSaveButton: string;
modalLoadButton: string;
projectMessageSlugRequired: string;
projectMessageSaveFailed: string;
projectMessageSaveError: string;
projectMessageSaveSuccessPrefix: string;
projectMessageLoadSlugRequired: string;
projectMessageLoadFailed: string;
projectMessageLoadLocalSuccessPrefix: string;
projectMessageLoadServerSuccessPrefix: string;
projectMessageInvalidFormat: string;
projectMessageLoadError: string;
projectMessageDeleteSlugMissing: string;
projectMessageDeleteFailed: string;
projectMessageDeleteSuccess: string;
projectMessageDeleteError: string;
jsonModalTitle: string;
jsonModalSubtitle: string;
jsonExportButton: string;
jsonClearCanvasButton: string;
jsonEditorStateLabel: string;
jsonImportLabel: string;
jsonApplyButton: string;
listItemToolbarMoveUpLabel: string;
listItemToolbarMoveDownLabel: string;
listItemToolbarIndentLabel: string;
listItemToolbarOutdentLabel: string;
imageBlockEmptyPreviewPlaceholder: string;
columnAreaLabel: string;
};
const EDITOR_MESSAGES: Record<AppLocale, EditorMessages> = {
en: {
headerTitle: "Page Editor",
headerDescription: "Build and edit your landing pages with a visual editor.",
navProjects: "Projects",
navPreview: "Open preview",
undoLabel: "Undo",
redoLabel: "Redo",
menuLabel: "Menu",
menuProjectSaveLoad: "Save / load project",
menuJsonImportExport: "JSON export / import",
menuExportZip: "Export as page files (ZIP)",
menuClearCanvas: "Clear canvas",
menuDeleteProject: "Delete project",
confirmClearCanvas:
"Clear all blocks on the canvas? This can only be undone with the undo history.",
confirmDeleteProject:
"Delete the current project? This cannot be undone. Local and autosave data will also be removed.",
modalProjectTitle: "Save / load project",
modalProjectTitleLabel: "Project title",
modalProjectSlugLabel: "Project address (e.g. my-landing)",
modalSaveButton: "Save (local + server)",
modalLoadButton: "Load",
projectMessageSlugRequired: "Please enter a project address.",
projectMessageSaveFailed: "Failed to save the project.",
projectMessageSaveError: "An error occurred while saving the project.",
projectMessageSaveSuccessPrefix: "Project saved: ",
projectMessageLoadSlugRequired: "Please enter an address to load.",
projectMessageLoadFailed: "Failed to load the project.",
projectMessageLoadLocalSuccessPrefix: "Loaded project from local: ",
projectMessageLoadServerSuccessPrefix: "Loaded project from server: ",
projectMessageInvalidFormat: "Project data format is invalid.",
projectMessageLoadError: "An error occurred while loading the project.",
projectMessageDeleteSlugMissing: "There is no project address to delete.",
projectMessageDeleteFailed: "Failed to delete the project.",
projectMessageDeleteSuccess: "Project deleted.",
projectMessageDeleteError: "An error occurred while deleting the project.",
jsonModalTitle: "JSON Export / Import",
jsonModalSubtitle: "* Import or export the current editor state as JSON.",
jsonExportButton: "Export JSON",
jsonClearCanvasButton: "Clear canvas",
jsonEditorStateLabel: "Editor state JSON",
jsonImportLabel: "Import from JSON",
jsonApplyButton: "Apply JSON",
listItemToolbarMoveUpLabel: "Move item up",
listItemToolbarMoveDownLabel: "Move item down",
listItemToolbarIndentLabel: "Indent item",
listItemToolbarOutdentLabel: "Outdent item",
imageBlockEmptyPreviewPlaceholder:
"Enter an image URL or upload a file to see the preview here.",
columnAreaLabel: "Column area",
},
ko: {
headerTitle: "페이지 에디터",
headerDescription: "랜딩 페이지를 시각적으로 구성할 수 있는 에디터입니다.",
navProjects: "프로젝트 목록",
navPreview: "프리뷰 열기",
undoLabel: "실행 취소",
redoLabel: "다시 실행",
menuLabel: "메뉴",
menuProjectSaveLoad: "프로젝트 저장/불러오기",
menuJsonImportExport: "JSON 내보내기/불러오기",
menuExportZip: "페이지 파일로 내보내기 (ZIP)",
menuClearCanvas: "캔버스 초기화",
menuDeleteProject: "프로젝트 삭제",
confirmClearCanvas:
"캔버스를 모두 초기화할까요? 이 작업은 실행 취소 히스토리로만 되돌릴 수 있습니다.",
confirmDeleteProject:
"현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
modalProjectTitle: "프로젝트 저장 / 불러오기",
modalProjectTitleLabel: "프로젝트 제목",
modalProjectSlugLabel: "프로젝트 주소 (예: my-landing)",
modalSaveButton: "저장 (로컬 + 서버)",
modalLoadButton: "불러오기",
projectMessageSlugRequired: "프로젝트 주소를 입력해 주세요.",
projectMessageSaveFailed: "프로젝트 저장에 실패했습니다.",
projectMessageSaveError: "프로젝트 저장 중 오류가 발생했습니다.",
projectMessageSaveSuccessPrefix: "프로젝트가 저장되었습니다: ",
projectMessageLoadSlugRequired: "불러올 주소를 입력해 주세요.",
projectMessageLoadFailed: "프로젝트를 불러오지 못했습니다.",
projectMessageLoadLocalSuccessPrefix: "로컬에서 프로젝트를 불러왔습니다: ",
projectMessageLoadServerSuccessPrefix: "프로젝트를 불러왔습니다: ",
projectMessageInvalidFormat: "프로젝트 데이터 형식이 올바르지 않습니다.",
projectMessageLoadError: "프로젝트 불러오기 중 오류가 발생했습니다.",
projectMessageDeleteSlugMissing: "삭제할 프로젝트 주소가 없습니다.",
projectMessageDeleteFailed: "프로젝트 삭제에 실패했습니다.",
projectMessageDeleteSuccess: "프로젝트가 삭제되었습니다.",
projectMessageDeleteError: "프로젝트 삭제 중 오류가 발생했습니다.",
jsonModalTitle: "JSON Export / Import",
jsonModalSubtitle: "* 에디터 내용을 가져오거나 내보낼 수 있습니다.",
jsonExportButton: "JSON 내보내기",
jsonClearCanvasButton: "캔버스 초기화",
jsonEditorStateLabel: "에디터 상태 JSON",
jsonImportLabel: "JSON에서 불러오기",
jsonApplyButton: "JSON 적용하기",
listItemToolbarMoveUpLabel: "아이템 위로 이동",
listItemToolbarMoveDownLabel: "아이템 아래로 이동",
listItemToolbarIndentLabel: "아이템 들여쓰기",
listItemToolbarOutdentLabel: "아이템 내어쓰기",
imageBlockEmptyPreviewPlaceholder:
"이미지 URL 을 입력하거나 파일을 업로드하면 여기에서 미리보기가 표시됩니다.",
columnAreaLabel: "컬럼 영역",
},
};
export function getEditorMessages(locale: AppLocale | null | undefined): EditorMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_MESSAGES[key] ?? EDITOR_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,121 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorBlockDefaultsMessages = {
text: {
defaultText: string;
};
list: {
firstItemText: string;
};
button: {
label: string;
};
form: {
nameLabel: string;
emailLabel: string;
messageLabel: string;
successMessage: string;
errorMessage: string;
};
formInput: {
label: string;
};
formSelect: {
label: string;
option1Label: string;
option2Label: string;
};
formCheckbox: {
groupLabel: string;
option1Label: string;
option2Label: string;
};
formRadio: {
groupLabel: string;
optionALabel: string;
optionBLabel: string;
};
};
const EDITOR_BLOCK_DEFAULTS_MESSAGES: Record<AppLocale, EditorBlockDefaultsMessages> = {
en: {
text: {
defaultText: "New text",
},
list: {
firstItemText: "List item 1",
},
button: {
label: "Button",
},
form: {
nameLabel: "Name",
emailLabel: "Email",
messageLabel: "Message",
successMessage: "Your message has been sent successfully.",
errorMessage: "An error occurred while submitting.",
},
formInput: {
label: "Input field",
},
formSelect: {
label: "Select field",
option1Label: "Option 1",
option2Label: "Option 2",
},
formCheckbox: {
groupLabel: "Checkbox group",
option1Label: "Check 1",
option2Label: "Check 2",
},
formRadio: {
groupLabel: "Radio group",
optionALabel: "Option A",
optionBLabel: "Option B",
},
},
ko: {
text: {
defaultText: "새 텍스트",
},
list: {
firstItemText: "리스트 아이템 1",
},
button: {
label: "버튼",
},
form: {
nameLabel: "이름",
emailLabel: "이메일",
messageLabel: "메시지",
successMessage: "성공적으로 전송되었습니다.",
errorMessage: "전송 중 오류가 발생했습니다.",
},
formInput: {
label: "입력 필드",
},
formSelect: {
label: "셀렉트 필드",
option1Label: "옵션 1",
option2Label: "옵션 2",
},
formCheckbox: {
groupLabel: "체크박스 그룹",
option1Label: "옵션 1",
option2Label: "옵션 2",
},
formRadio: {
groupLabel: "라디오 그룹",
optionALabel: "옵션 A",
optionBLabel: "옵션 B",
},
},
};
export function getEditorBlockDefaultsMessages(
locale: AppLocale | null | undefined,
): EditorBlockDefaultsMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_BLOCK_DEFAULTS_MESSAGES[key] ?? EDITOR_BLOCK_DEFAULTS_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,286 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorButtonPanelMessages = {
buttonTextLabel: string;
buttonTextAria: string;
imageSectionTitle: string;
imageSourceLabel: string;
imageSourceAria: string;
imageSourceOptionNone: string;
imageSourceOptionUrl: string;
imageSourceOptionUpload: string;
imageUrlLabel: string;
imageUrlAria: string;
imageUploadLabel: string;
imageUploadAria: string;
imageAltLabel: string;
imageAltAria: string;
imagePlacementLabel: string;
imagePlacementAria: string;
imagePlacementOptionLeft: string;
imagePlacementOptionRight: string;
imagePlacementOptionTop: string;
imagePlacementOptionBottom: string;
paddingXLabel: string;
paddingYLabel: string;
linkLabel: string;
linkAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
styleLabel: string;
styleAria: string;
styleOptionSolid: string;
styleOptionOutline: string;
styleOptionGhost: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
fixedWidthPresetSmall: string;
fixedWidthPresetMedium: string;
fixedWidthPresetLarge: string;
fontSizeLabel: string;
fontSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightPresetTight: string;
lineHeightPresetNormal: string;
lineHeightPresetRelaxed: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
letterSpacingPresetTighter: string;
letterSpacingPresetTight: string;
letterSpacingPresetNormal: string;
letterSpacingPresetWide: string;
letterSpacingPresetWider: string;
};
const EDITOR_BUTTON_PANEL_MESSAGES: Record<AppLocale, EditorButtonPanelMessages> = {
en: {
buttonTextLabel: "Button text",
buttonTextAria: "Button text",
imageSectionTitle: "Button image",
imageSourceLabel: "Button image source",
imageSourceAria: "Button image source",
imageSourceOptionNone: "None",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "File upload",
imageUrlLabel: "Button image URL",
imageUrlAria: "Button image URL",
imageUploadLabel: "Button image file upload",
imageUploadAria: "Button image file upload",
imageAltLabel: "Button image alt text",
imageAltAria: "Button image alt text",
imagePlacementLabel: "Button image placement",
imagePlacementAria: "Button image placement",
imagePlacementOptionLeft: "Text left",
imagePlacementOptionRight: "Text right",
imagePlacementOptionTop: "Text top",
imagePlacementOptionBottom: "Text bottom",
paddingXLabel: "Horizontal padding",
paddingYLabel: "Vertical padding",
linkLabel: "Button link",
linkAria: "Button link",
alignLabel: "Alignment",
alignAria: "Button alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
textColorLabel: "Text color",
textColorPickerAria: "Button text color picker",
textColorHexAria: "Button text color HEX",
styleLabel: "Style",
styleAria: "Button style",
styleOptionSolid: "Fill",
styleOptionOutline: "Outline",
styleOptionGhost: "Ghost",
fillColorLabel: "Fill color",
fillColorPickerAria: "Button fill color picker",
fillColorHexAria: "Button fill color HEX",
strokeColorLabel: "Border color",
strokeColorPickerAria: "Button border color picker",
strokeColorHexAria: "Button border color HEX",
borderRadiusLabel: "Border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Full",
widthModeLabel: "Button width mode",
widthModeAria: "Button width mode",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed",
fixedWidthLabel: "Button fixed width",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "Small",
fixedWidthPresetMedium: "Medium",
fixedWidthPresetLarge: "Wide",
fontSizeLabel: "Button font size",
fontSizeUnitLabel: "(px)",
lineHeightLabel: "Line height",
lineHeightPresetTight: "Tight",
lineHeightPresetNormal: "Normal",
lineHeightPresetRelaxed: "Relaxed",
letterSpacingLabel: "Letter spacing",
letterSpacingUnitLabel: "(px)",
letterSpacingPresetTighter: "Very tight",
letterSpacingPresetTight: "Tight",
letterSpacingPresetNormal: "Normal",
letterSpacingPresetWide: "Wide",
letterSpacingPresetWider: "Very wide",
},
ko: {
buttonTextLabel: "버튼 텍스트",
buttonTextAria: "버튼 텍스트",
imageSectionTitle: "버튼 이미지",
imageSourceLabel: "버튼 이미지 소스",
imageSourceAria: "버튼 이미지 소스",
imageSourceOptionNone: "사용 안 함",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "파일 업로드",
imageUrlLabel: "버튼 이미지 URL",
imageUrlAria: "버튼 이미지 URL",
imageUploadLabel: "버튼 이미지 파일 업로드",
imageUploadAria: "버튼 이미지 파일 업로드",
imageAltLabel: "버튼 이미지 대체 텍스트",
imageAltAria: "버튼 이미지 대체 텍스트",
imagePlacementLabel: "버튼 이미지 위치",
imagePlacementAria: "버튼 이미지 위치",
imagePlacementOptionLeft: "텍스트 왼쪽",
imagePlacementOptionRight: "텍스트 오른쪽",
imagePlacementOptionTop: "텍스트 위쪽",
imagePlacementOptionBottom: "텍스트 아래쪽",
paddingXLabel: "가로 패딩",
paddingYLabel: "세로 패딩",
linkLabel: "버튼 링크",
linkAria: "버튼 링크",
alignLabel: "정렬",
alignAria: "버튼 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
textColorLabel: "텍스트 색상",
textColorPickerAria: "버튼 텍스트 색상 피커",
textColorHexAria: "버튼 텍스트 색상 HEX",
styleLabel: "스타일",
styleAria: "버튼 스타일",
styleOptionSolid: "채움",
styleOptionOutline: "외곽선",
styleOptionGhost: "고스트",
fillColorLabel: "채움 색상",
fillColorPickerAria: "버튼 채움 색상 피커",
fillColorHexAria: "버튼 채움 색상 HEX",
strokeColorLabel: "외곽선 색상",
strokeColorPickerAria: "버튼 외곽선 색상 피커",
strokeColorHexAria: "버튼 외곽선 색상 HEX",
borderRadiusLabel: "모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
widthModeLabel: "버튼 너비 모드",
widthModeAria: "버튼 너비 모드",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "버튼 고정 너비",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "작게",
fixedWidthPresetMedium: "보통",
fixedWidthPresetLarge: "넓게",
fontSizeLabel: "버튼 크기",
fontSizeUnitLabel: "(px)",
lineHeightLabel: "줄 간격",
lineHeightPresetTight: "좁게",
lineHeightPresetNormal: "보통",
lineHeightPresetRelaxed: "넓게",
letterSpacingLabel: "글자 간격",
letterSpacingUnitLabel: "(px)",
letterSpacingPresetTighter: "아주 좁게",
letterSpacingPresetTight: "좁게",
letterSpacingPresetNormal: "보통",
letterSpacingPresetWide: "넓게",
letterSpacingPresetWider: "아주 넓게",
},
};
export function getEditorButtonPanelMessages(
locale: AppLocale | null | undefined,
): EditorButtonPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_BUTTON_PANEL_MESSAGES[key] ?? EDITOR_BUTTON_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,40 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorCanvasMessages = {
emptyStateHint: string;
previewFallbackTextBlock: string;
previewFallbackButtonBlock: string;
previewFallbackImageBlock: string;
previewFallbackListBlock: string;
previewFallbackDividerBlock: string;
previewFallbackSectionBlock: string;
};
const EDITOR_CANVAS_MESSAGES: Record<AppLocale, EditorCanvasMessages> = {
en: {
emptyStateHint: 'Click the "Text" button on the left to add your first block.',
previewFallbackTextBlock: "Text block",
previewFallbackButtonBlock: "Button block",
previewFallbackImageBlock: "Image block",
previewFallbackListBlock: "List block",
previewFallbackDividerBlock: "Divider block",
previewFallbackSectionBlock: "Section block",
},
ko: {
emptyStateHint: '왼쪽에서 "텍스트" 버튼을 눌러 블록을 추가해 보세요.',
previewFallbackTextBlock: "텍스트 블록",
previewFallbackButtonBlock: "버튼 블록",
previewFallbackImageBlock: "이미지 블록",
previewFallbackListBlock: "리스트 블록",
previewFallbackDividerBlock: "구분선 블록",
previewFallbackSectionBlock: "섹션 블록",
},
};
export function getEditorCanvasMessages(
locale: AppLocale | null | undefined,
): EditorCanvasMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_CANVAS_MESSAGES[key] ?? EDITOR_CANVAS_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,70 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorColorPickerFieldMessages = {
dropdownLabelDefault: string;
hexPlaceholder: string;
paletteLabels: Record<string, string>;
};
const EDITOR_COLOR_PICKER_FIELD_MESSAGES: Record<AppLocale, EditorColorPickerFieldMessages> = {
en: {
dropdownLabelDefault: "Color palette",
hexPlaceholder: "e.g. #ff0000",
paletteLabels: {
default: "None",
transparent: "Transparent",
muted: "Muted",
strong: "Strong highlight",
neutral: "Neutral",
accent: "Accent",
"accent-deep": "Accent (deep)",
"accent-soft": "Accent (soft)",
danger: "Danger",
"danger-deep": "Danger (strong)",
success: "Success",
"success-soft": "Success (soft)",
warning: "Warning",
info: "Info",
purple: "Purple",
"purple-soft": "Purple (soft)",
pink: "Pink",
"pink-soft": "Pink (soft)",
dark: "Dark text",
"dark-muted": "Dark muted",
},
},
ko: {
dropdownLabelDefault: "색상 팔레트",
hexPlaceholder: "예: #ff0000",
paletteLabels: {
default: "없음",
transparent: "투명",
muted: "연한",
strong: "강조",
neutral: "중립",
accent: "포인트",
"accent-deep": "포인트 진하게",
"accent-soft": "포인트 연하게",
danger: "위험",
"danger-deep": "위험 진하게",
success: "성공",
"success-soft": "성공 연하게",
warning: "경고",
info: "정보",
purple: "보라",
"purple-soft": "연한 보라",
pink: "핑크",
"pink-soft": "연한 핑크",
dark: "어두운 텍스트",
"dark-muted": "어두운 연한",
},
},
};
export function getEditorColorPickerFieldMessages(
locale: AppLocale | null | undefined,
): EditorColorPickerFieldMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_COLOR_PICKER_FIELD_MESSAGES[key] ?? EDITOR_COLOR_PICKER_FIELD_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,122 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorDividerPropertiesPanelMessages = {
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
thicknessLabel: string;
thicknessAria: string;
thicknessOptionThin: string;
thicknessOptionMedium: string;
styleSectionTitle: string;
lengthModeLabel: string;
lengthModeAria: string;
lengthModeOptionAuto: string;
lengthModeOptionFull: string;
lengthModeOptionFixed: string;
fixedLengthLabel: string;
fixedLengthUnitLabel: string;
fixedLengthPresetShort: string;
fixedLengthPresetNormal: string;
fixedLengthPresetLong: string;
colorLabel: string;
colorPickerAria: string;
colorHexAria: string;
marginLabel: string;
marginUnitLabel: string;
marginPresetSmall: string;
marginPresetNormal: string;
marginPresetLarge: string;
};
const EDITOR_DIVIDER_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorDividerPropertiesPanelMessages> = {
en: {
alignLabel: "Alignment",
alignAria: "Divider alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
thicknessLabel: "Thickness",
thicknessAria: "Divider thickness",
thicknessOptionThin: "Thin",
thicknessOptionMedium: "Normal",
styleSectionTitle: "Divider style",
lengthModeLabel: "Length mode",
lengthModeAria: "Divider length mode",
lengthModeOptionAuto: "Fit content",
lengthModeOptionFull: "Full width",
lengthModeOptionFixed: "Fixed length",
fixedLengthLabel: "Fixed length",
fixedLengthUnitLabel: "(px)",
fixedLengthPresetShort: "Short",
fixedLengthPresetNormal: "Normal",
fixedLengthPresetLong: "Long",
colorLabel: "Line color",
colorPickerAria: "Divider color picker",
colorHexAria: "Divider color HEX",
marginLabel: "Vertical margin",
marginUnitLabel: "(px)",
marginPresetSmall: "Small",
marginPresetNormal: "Normal",
marginPresetLarge: "Large",
},
ko: {
alignLabel: "정렬",
alignAria: "구분선 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
thicknessLabel: "두께",
thicknessAria: "구분선 두께",
thicknessOptionThin: "얇게",
thicknessOptionMedium: "보통",
styleSectionTitle: "구분선 스타일",
lengthModeLabel: "길이 모드",
lengthModeAria: "구분선 길이 모드",
lengthModeOptionAuto: "내용에 맞춤",
lengthModeOptionFull: "전체 폭",
lengthModeOptionFixed: "고정 길이",
fixedLengthLabel: "고정 길이",
fixedLengthUnitLabel: "(px)",
fixedLengthPresetShort: "짧게",
fixedLengthPresetNormal: "보통",
fixedLengthPresetLong: "길게",
colorLabel: "선 색상",
colorPickerAria: "구분선 색상 피커",
colorHexAria: "구분선 색상 HEX",
marginLabel: "위/아래 여백",
marginUnitLabel: "(px)",
marginPresetSmall: "작게",
marginPresetNormal: "보통",
marginPresetLarge: "크게",
},
};
export function getEditorDividerPropertiesPanelMessages(
locale: AppLocale | null | undefined,
): EditorDividerPropertiesPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_DIVIDER_PROPERTIES_PANEL_MESSAGES[key] ?? EDITOR_DIVIDER_PROPERTIES_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,298 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormCheckboxPanelMessages = {
sectionTitle: string;
groupTitleTypeLabel: string;
groupTitleTypeOptionText: string;
groupTitleTypeOptionImage: string;
groupTitleDisplayLabel: string;
groupTitleDisplayOptionVisible: string;
groupTitleDisplayOptionHidden: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
optionLayoutLabel: string;
optionLayoutOptionStacked: string;
optionLayoutOptionInline: string;
labelGapLabel: string;
groupTitleLabel: string;
submitKeyLabel: string;
optionsLabel: string;
addOptionButtonLabel: string;
optionLabelPlaceholder: string;
optionValuePlaceholder: string;
optionImageSourceLabel: string;
optionImageSourceOptionUrl: string;
optionImageSourceOptionUpload: string;
optionImageUrlPlaceholder: string;
optionImageUploadLabel: string;
optionImageUploadAria: string;
groupTitleImageSourceLabel: string;
groupTitleImageSourceOptionUrl: string;
groupTitleImageSourceOptionUpload: string;
groupTitleImageUrlLabel: string;
groupTitleImageUploadLabel: string;
groupTitleImageUploadAria: string;
requiredNoticeText: string;
styleSectionTitle: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
widthModeLabel: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
optionGapLabel: string;
optionGapUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_CHECKBOX_PANEL_MESSAGES: Record<AppLocale, EditorFormCheckboxPanelMessages> = {
en: {
sectionTitle: "Checkbox field",
groupTitleTypeLabel: "Group title type",
groupTitleTypeOptionText: "Text",
groupTitleTypeOptionImage: "Image",
groupTitleDisplayLabel: "Group title display mode",
groupTitleDisplayOptionVisible: "Visible (default)",
groupTitleDisplayOptionHidden: "Hidden",
layoutLabel: "Layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
optionLayoutLabel: "Option layout",
optionLayoutOptionStacked: "Vertical (default)",
optionLayoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap",
groupTitleLabel: "Group title",
submitKeyLabel: "Submit key",
optionsLabel: "Options",
addOptionButtonLabel: "Add option",
optionLabelPlaceholder: "Label",
optionValuePlaceholder: "Value (value)",
optionImageSourceLabel: "Option image source",
optionImageSourceOptionUrl: "URL",
optionImageSourceOptionUpload: "File upload",
optionImageUrlPlaceholder: "Option image URL (optional)",
optionImageUploadLabel: "Option image file upload",
optionImageUploadAria: "Option image file upload",
groupTitleImageSourceLabel: "Group title image source",
groupTitleImageSourceOptionUrl: "URL",
groupTitleImageSourceOptionUpload: "File upload",
groupTitleImageUrlLabel: "Group title image URL",
groupTitleImageUploadLabel: "Group title image file upload",
groupTitleImageUploadAria: "Group title image file upload",
requiredNoticeText: "Required state is configured in the form controller.",
styleSectionTitle: "Field style",
textSizeLabel: "Checkbox text size",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Checkbox line height",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Checkbox letter spacing",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "Field width",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed value",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Checkbox horizontal padding",
paddingXUnitLabel: "(px)",
paddingYLabel: "Checkbox vertical padding",
paddingYUnitLabel: "(px)",
optionGapLabel: "Checkbox option gap",
optionGapUnitLabel: "(px)",
textColorLabel: "Text color",
textColorPickerAria: "Checkbox text color picker",
textColorHexAria: "Checkbox text color HEX",
fillColorLabel: "Background color",
fillColorPickerAria: "Checkbox background color picker",
fillColorHexAria: "Checkbox background color HEX",
strokeColorLabel: "Border color",
strokeColorPickerAria: "Checkbox border color picker",
strokeColorHexAria: "Checkbox border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "체크박스 필드",
groupTitleTypeLabel: "그룹 타이틀 타입",
groupTitleTypeOptionText: "텍스트",
groupTitleTypeOptionImage: "이미지",
groupTitleDisplayLabel: "그룹 타이틀 표시 방식",
groupTitleDisplayOptionVisible: "표시 (기본)",
groupTitleDisplayOptionHidden: "숨김",
layoutLabel: "레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
optionLayoutLabel: "옵션 레이아웃",
optionLayoutOptionStacked: "세로 (기본)",
optionLayoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격",
groupTitleLabel: "그룹 타이틀",
submitKeyLabel: "전송 키",
optionsLabel: "옵션",
addOptionButtonLabel: "옵션 추가",
optionLabelPlaceholder: "라벨",
optionValuePlaceholder: "값(value)",
optionImageSourceLabel: "옵션 이미지 소스",
optionImageSourceOptionUrl: "URL",
optionImageSourceOptionUpload: "파일 업로드",
optionImageUrlPlaceholder: "옵션 이미지 URL (선택)",
optionImageUploadLabel: "옵션 이미지 파일 업로드",
optionImageUploadAria: "옵션 이미지 파일 업로드",
groupTitleImageSourceLabel: "그룹 타이틀 이미지 소스",
groupTitleImageSourceOptionUrl: "URL",
groupTitleImageSourceOptionUpload: "파일 업로드",
groupTitleImageUrlLabel: "그룹 타이틀 이미지 URL",
groupTitleImageUploadLabel: "그룹 타이틀 이미지 파일 업로드",
groupTitleImageUploadAria: "그룹 타이틀 이미지 파일 업로드",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
styleSectionTitle: "필드 스타일",
textSizeLabel: "체크박스 텍스트 크기",
textSizeUnitLabel: "(px)",
lineHeightLabel: "체크박스 줄간격",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "체크박스 자간",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "필드 너비",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "체크박스 가로 패딩",
paddingXUnitLabel: "(px)",
paddingYLabel: "체크박스 세로 패딩",
paddingYUnitLabel: "(px)",
optionGapLabel: "체크박스 옵션 간격",
optionGapUnitLabel: "(px)",
textColorLabel: "텍스트 색상",
textColorPickerAria: "체크박스 텍스트 색상 피커",
textColorHexAria: "체크박스 텍스트 색상 HEX",
fillColorLabel: "배경 색상",
fillColorPickerAria: "체크박스 배경 색상 피커",
fillColorHexAria: "체크박스 배경 색상 HEX",
strokeColorLabel: "테두리 색상",
strokeColorPickerAria: "체크박스 테두리 색상 피커",
strokeColorHexAria: "체크박스 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormCheckboxPanelMessages(
locale: AppLocale | null | undefined,
): EditorFormCheckboxPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_CHECKBOX_PANEL_MESSAGES[key] ?? EDITOR_FORM_CHECKBOX_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,223 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormControllerPanelMessages = {
introTextPrefix: string;
introTextSuffix: string;
submitTargetLabel: string;
submitTargetOptionInternal: string;
submitTargetOptionWebhook: string;
webhookUrlLabel: string;
webhookUrlPlaceholder: string;
payloadFormatLabel: string;
payloadFormatOptionForm: string;
payloadFormatOptionJson: string;
httpMethodLabel: string;
authTokenLabel: string;
authTokenPlaceholder: string;
extraParamsLabel: string;
extraParamsPlaceholder: string;
layoutSectionTitle: string;
formWidthModeLabel: string;
formWidthModeOptionAuto: string;
formWidthModeOptionFull: string;
formWidthModeOptionFixed: string;
formFixedWidthLabel: string;
formFixedWidthPresetNarrow: string;
formFixedWidthPresetNormal: string;
formFixedWidthPresetWide: string;
marginYLabel: string;
marginYPresetNone: string;
marginYPresetCompact: string;
marginYPresetNormal: string;
marginYPresetSpacious: string;
messagesSectionTitle: string;
successMessageLabel: string;
successMessagePlaceholder: string;
errorMessageLabel: string;
errorMessagePlaceholder: string;
controllerSectionTitle: string;
fieldMappingLegend: string;
fieldMappingAriaLabel: string;
requiredLabel: string;
submitButtonLabel: string;
submitButtonAriaLabel: string;
submitButtonNoneOptionLabel: string;
sheetsGuideButtonLabel: string;
sheetsGuideHeading: string;
sheetsGuideCloseAriaLabel: string;
sheetsGuideIntro: string;
sheetsGuideStep1: string;
sheetsGuideStep2: string;
sheetsGuideStep3: string;
sheetsGuideExampleCodeLabel: string;
};
const EDITOR_FORM_CONTROLLER_PANEL_MESSAGES: Record<AppLocale, EditorFormControllerPanelMessages> = {
en: {
introTextPrefix:
"This form is first submitted to the ",
introTextSuffix:
" endpoint on the public page, then processed internally or forwarded to a webhook depending on the settings.",
submitTargetLabel: "Submit target",
submitTargetOptionInternal: "Internal only (no webhook)",
submitTargetOptionWebhook: "Forward to external webhook / Google Sheets",
webhookUrlLabel: "Webhook URL (e.g. Google Apps Script web app URL)",
webhookUrlPlaceholder: "e.g. https://script.google.com/macros/s/.../exec",
payloadFormatLabel: "Payload format",
payloadFormatOptionForm: "Form data (x-www-form-urlencoded)",
payloadFormatOptionJson: "JSON (application/json)",
httpMethodLabel: "HTTP method",
authTokenLabel: "Authorization token (optional)",
authTokenPlaceholder: "e.g. Bearer xxxxxx",
extraParamsLabel: "Additional parameters (key=value lines, separated by line breaks)",
extraParamsPlaceholder: "e.g.\nsource=landing\nformId=contact-hero",
layoutSectionTitle: "Form layout",
formWidthModeLabel: "Form width mode",
formWidthModeOptionAuto: "Auto",
formWidthModeOptionFull: "Full width",
formWidthModeOptionFixed: "Fixed value",
formFixedWidthLabel: "Form fixed width",
formFixedWidthPresetNarrow: "Narrow",
formFixedWidthPresetNormal: "Normal",
formFixedWidthPresetWide: "Wide",
marginYLabel: "Form top/bottom margin",
marginYPresetNone: "None",
marginYPresetCompact: "Compact",
marginYPresetNormal: "Normal",
marginYPresetSpacious: "Spacious",
messagesSectionTitle: "Form messages",
successMessageLabel: "Success message",
successMessagePlaceholder: "e.g. Your message has been sent successfully.",
errorMessageLabel: "Error message",
errorMessagePlaceholder: "e.g. An error occurred while submitting.",
controllerSectionTitle: "Form controller",
fieldMappingLegend: "Form field mapping",
fieldMappingAriaLabel: "Form field mapping",
requiredLabel: "Required",
submitButtonLabel: "Submit button",
submitButtonAriaLabel: "Submit button",
submitButtonNoneOptionLabel: "(None)",
sheetsGuideButtonLabel: "Google Sheets integration guide",
sheetsGuideHeading: "Google Sheets integration guide",
sheetsGuideCloseAriaLabel: "Close Google Sheets integration guide",
sheetsGuideIntro:
"You must enter the Google Apps Script web app URL, not the spreadsheet URL itself. Follow the steps below to connect Google Sheets without writing code.",
sheetsGuideStep1:
"In Google Sheets, create a new spreadsheet and add headers like name, email, message in the first row.",
sheetsGuideStep2:
"From the menu, open \"Extensions → Apps Script\" and paste the example code below.",
sheetsGuideStep3:
"From \"Deploy → New deployment\", deploy as a web app and paste the issued web app URL (…/exec) into the Webhook URL field.",
sheetsGuideExampleCodeLabel: "Example Apps Script code",
},
ko: {
introTextPrefix:
"이 폼은 퍼블릭 페이지에서 ",
introTextSuffix:
" 엔드포인트로 먼저 전송된 뒤, 설정에 따라 내부 처리 또는 Webhook 으로 전달됩니다.",
submitTargetLabel: "전송 대상",
submitTargetOptionInternal: "서버 처리 전용 (Webhook 없이 사용)",
submitTargetOptionWebhook: "외부 Webhook / Google Sheets 로 전달",
webhookUrlLabel: "Webhook URL (예: Google Apps Script 웹 앱 URL)",
webhookUrlPlaceholder: "예: https://script.google.com/macros/s/.../exec",
payloadFormatLabel: "전송 포맷",
payloadFormatOptionForm: "폼 데이터 (x-www-form-urlencoded)",
payloadFormatOptionJson: "JSON (application/json)",
httpMethodLabel: "HTTP 메서드",
authTokenLabel: "Authorization 토큰 (옵션)",
authTokenPlaceholder: "예: Bearer xxxxxx",
extraParamsLabel: "추가 파라미터 (key=value 형식, 줄바꿈으로 구분)",
extraParamsPlaceholder: "예:\nsource=landing\nformId=contact-hero",
layoutSectionTitle: "폼 레이아웃",
formWidthModeLabel: "폼 너비 모드",
formWidthModeOptionAuto: "자동",
formWidthModeOptionFull: "전체 폭",
formWidthModeOptionFixed: "고정 값",
formFixedWidthLabel: "폼 고정 너비",
formFixedWidthPresetNarrow: "좁게",
formFixedWidthPresetNormal: "보통",
formFixedWidthPresetWide: "넓게",
marginYLabel: "폼 위/아래 여백",
marginYPresetNone: "없음",
marginYPresetCompact: "좁게",
marginYPresetNormal: "보통",
marginYPresetSpacious: "넓게",
messagesSectionTitle: "폼 메시지",
successMessageLabel: "성공 메시지",
successMessagePlaceholder: "예: 성공적으로 전송되었습니다.",
errorMessageLabel: "에러 메시지",
errorMessagePlaceholder: "예: 전송 중 오류가 발생했습니다.",
controllerSectionTitle: "폼 컨트롤러",
fieldMappingLegend: "폼 필드 매핑",
fieldMappingAriaLabel: "폼 필드 매핑",
requiredLabel: "필수",
submitButtonLabel: "Submit 버튼",
submitButtonAriaLabel: "Submit 버튼",
submitButtonNoneOptionLabel: "선택 안 함",
sheetsGuideButtonLabel: "Google Sheets 연동 가이드",
sheetsGuideHeading: "Google Sheets 연동 가이드",
sheetsGuideCloseAriaLabel: "Google Sheets 연동 가이드 닫기",
sheetsGuideIntro:
"구글 시트 주소를 그대로 입력하는 것이 아니라, 시트에 연결된 Google Apps Script 웹 앱 URL 을 입력해야 합니다. 아래 순서를 따르면 코드를 잘 모르는 사용자도 연동할 수 있습니다.",
sheetsGuideStep1:
"Google Sheets 에서 새 스프레드시트를 만들고, 1행에 name, email, message 같은 헤더를 추가합니다.",
sheetsGuideStep2:
'시트 메뉴의 "확장 프로그램 → 앱스 스크립트" 를 열고 아래 예제 코드를 붙여넣습니다.',
sheetsGuideStep3:
'"배포 → 새 배포" 에서 웹 앱으로 배포한 뒤, 발급된 웹 앱 URL(…/exec) 을 Webhook URL 칸에 붙여넣습니다.',
sheetsGuideExampleCodeLabel: "예시 Apps Script 코드",
},
};
export function getEditorFormControllerPanelMessages(
locale: AppLocale | null | undefined,
): EditorFormControllerPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_CONTROLLER_PANEL_MESSAGES[key] ?? EDITOR_FORM_CONTROLLER_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,359 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormInputPanelMessages = {
sectionTitle: string;
styleSectionTitle: string;
labelTypeLabel: string;
labelTypeOptionText: string;
labelTypeOptionImage: string;
fieldLabelLabel: string;
labelDisplayLabel: string;
labelDisplayOptionVisible: string;
labelDisplayOptionHidden: string;
labelDisplayOptionFloating: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
labelGapLabel: string;
labelGapUnitLabel: string;
// Presets for label gap
labelGapPresetTight: string;
labelGapPresetNormal: string;
labelGapPresetRelaxed: string;
labelGapPresetExtra: string;
labelImageUrlLabel: string;
labelImageAltLabel: string;
submitKeyLabel: string;
fieldTypeLabel: string;
fieldTypeAria: string;
fieldTypeOptionText: string;
fieldTypeOptionEmail: string;
fieldTypeOptionTextarea: string;
placeholderLabel: string;
placeholderAria: string;
requiredNoticeText: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
// Presets for line height
lineHeightPresetTight: string;
lineHeightPresetNormal: string;
lineHeightPresetLoose: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
// Presets for letter spacing
letterSpacingPresetTight: string;
letterSpacingPresetNormal: string;
letterSpacingPresetWide: string;
textAlignLabel: string;
textAlignOptionLeft: string;
textAlignOptionCenter: string;
textAlignOptionRight: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
// Presets for fixed width
fixedWidthPresetSmall: string;
fixedWidthPresetMedium: string;
fixedWidthPresetLarge: string;
paddingXLabel: string;
paddingXUnitLabel: string;
// Presets for horizontal padding
paddingXPresetXs: string;
paddingXPresetSm: string;
paddingXPresetMd: string;
paddingXPresetLg: string;
paddingXPresetXl: string;
paddingYLabel: string;
paddingYUnitLabel: string;
// Presets for vertical padding
paddingYPresetXs: string;
paddingYPresetSm: string;
paddingYPresetMd: string;
paddingYPresetLg: string;
paddingYPresetXl: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_INPUT_PANEL_MESSAGES: Record<AppLocale, EditorFormInputPanelMessages> = {
en: {
sectionTitle: "Input field",
styleSectionTitle: "Field style",
labelTypeLabel: "Label type",
labelTypeOptionText: "Text",
labelTypeOptionImage: "Image",
fieldLabelLabel: "Field label",
labelDisplayLabel: "Label display mode",
labelDisplayOptionVisible: "Visible (default)",
labelDisplayOptionHidden: "Hidden",
labelDisplayOptionFloating: "Floating label",
layoutLabel: "Layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap",
labelGapUnitLabel: "(px)",
labelGapPresetTight: "Tight",
labelGapPresetNormal: "Normal",
labelGapPresetRelaxed: "Relaxed",
labelGapPresetExtra: "Extra",
labelImageUrlLabel: "Label image URL",
labelImageAltLabel: "Label image alt text",
submitKeyLabel: "Submit key",
fieldTypeLabel: "Field type",
fieldTypeAria: "Field type",
fieldTypeOptionText: "Text",
fieldTypeOptionEmail: "Email",
fieldTypeOptionTextarea: "Multiline text",
placeholderLabel: "Placeholder",
placeholderAria: "Placeholder",
requiredNoticeText: "Required state is configured in the form controller.",
textSizeLabel: "Field text size",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Field line height",
lineHeightUnitLabel: "(px)",
lineHeightPresetTight: "Tight",
lineHeightPresetNormal: "Normal",
lineHeightPresetLoose: "Loose",
letterSpacingLabel: "Field letter spacing",
letterSpacingUnitLabel: "(px)",
letterSpacingPresetTight: "Tight",
letterSpacingPresetNormal: "Normal",
letterSpacingPresetWide: "Wide",
textAlignLabel: "Text alignment",
textAlignOptionLeft: "Left",
textAlignOptionCenter: "Center",
textAlignOptionRight: "Right",
widthModeLabel: "Width",
widthModeAria: "Width",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed value",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "Small",
fixedWidthPresetMedium: "Medium",
fixedWidthPresetLarge: "Large",
paddingXLabel: "Field horizontal padding",
paddingXUnitLabel: "(px)",
paddingXPresetXs: "Extra thin",
paddingXPresetSm: "Thin",
paddingXPresetMd: "Normal",
paddingXPresetLg: "Wide",
paddingXPresetXl: "Extra wide",
paddingYLabel: "Field vertical padding",
paddingYUnitLabel: "(px)",
paddingYPresetXs: "Extra thin",
paddingYPresetSm: "Thin",
paddingYPresetMd: "Normal",
paddingYPresetLg: "Wide",
paddingYPresetXl: "Extra wide",
textColorLabel: "Field text color",
textColorPickerAria: "Field text color picker",
textColorHexAria: "Field text color HEX",
fillColorLabel: "Field fill color",
fillColorPickerAria: "Field fill color picker",
fillColorHexAria: "Field fill color HEX",
strokeColorLabel: "Field border color",
strokeColorPickerAria: "Field border color picker",
strokeColorHexAria: "Field border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "입력 필드",
styleSectionTitle: "필드 스타일",
labelTypeLabel: "라벨 타입",
labelTypeOptionText: "텍스트",
labelTypeOptionImage: "이미지",
fieldLabelLabel: "필드 라벨",
labelDisplayLabel: "라벨 표시 방식",
labelDisplayOptionVisible: "표시 (기본)",
labelDisplayOptionHidden: "숨김",
labelDisplayOptionFloating: "플로팅 라벨",
layoutLabel: "레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격",
labelGapUnitLabel: "(px)",
labelGapPresetTight: "타이트",
labelGapPresetNormal: "보통",
labelGapPresetRelaxed: "느슨",
labelGapPresetExtra: "넓게",
labelImageUrlLabel: "라벨 이미지 URL",
labelImageAltLabel: "라벨 이미지 대체 텍스트",
submitKeyLabel: "전송 키",
fieldTypeLabel: "필드 타입",
fieldTypeAria: "필드 타입",
fieldTypeOptionText: "텍스트",
fieldTypeOptionEmail: "이메일",
fieldTypeOptionTextarea: "긴 텍스트",
placeholderLabel: "Placeholder",
placeholderAria: "Placeholder",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
textSizeLabel: "필드 텍스트 크기",
textSizeUnitLabel: "(px)",
lineHeightLabel: "필드 줄간격",
lineHeightUnitLabel: "(px)",
lineHeightPresetTight: "타이트",
lineHeightPresetNormal: "보통",
lineHeightPresetLoose: "루즈",
letterSpacingLabel: "필드 자간",
letterSpacingUnitLabel: "(px)",
letterSpacingPresetTight: "좁게",
letterSpacingPresetNormal: "보통",
letterSpacingPresetWide: "넓게",
textAlignLabel: "텍스트 정렬",
textAlignOptionLeft: "왼쪽",
textAlignOptionCenter: "가운데",
textAlignOptionRight: "오른쪽",
widthModeLabel: "너비",
widthModeAria: "너비",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "작게",
fixedWidthPresetMedium: "보통",
fixedWidthPresetLarge: "넓게",
paddingXLabel: "필드 가로 패딩",
paddingXUnitLabel: "(px)",
paddingXPresetXs: "아주 얇게",
paddingXPresetSm: "얇게",
paddingXPresetMd: "보통",
paddingXPresetLg: "넓게",
paddingXPresetXl: "아주 넓게",
paddingYLabel: "필드 세로 패딩",
paddingYUnitLabel: "(px)",
paddingYPresetXs: "아주 얇게",
paddingYPresetSm: "얇게",
paddingYPresetMd: "보통",
paddingYPresetLg: "넓게",
paddingYPresetXl: "아주 넓게",
textColorLabel: "필드 텍스트 색상",
textColorPickerAria: "필드 텍스트 색상 피커",
textColorHexAria: "필드 텍스트 색상 HEX",
fillColorLabel: "필드 채움 색상",
fillColorPickerAria: "필드 채움 색상 피커",
fillColorHexAria: "필드 채움 색상 HEX",
strokeColorLabel: "필드 테두리 색상",
strokeColorPickerAria: "필드 테두리 색상 피커",
strokeColorHexAria: "필드 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormInputPanelMessages(locale: AppLocale | null | undefined): EditorFormInputPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_INPUT_PANEL_MESSAGES[key] ?? EDITOR_FORM_INPUT_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,282 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormRadioPanelMessages = {
sectionTitle: string;
// Group title & option configuration
groupTitleTypeLabel: string;
groupTitleTypeOptionText: string;
groupTitleTypeOptionImage: string;
groupTitleDisplayLabel: string;
groupTitleDisplayOptionVisible: string;
groupTitleDisplayOptionHidden: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
optionLayoutLabel: string;
optionLayoutOptionStacked: string;
optionLayoutOptionInline: string;
labelGapLabel: string;
groupTitleLabel: string;
submitKeyLabel: string;
optionsLabel: string;
addOptionButtonLabel: string;
optionLabelPlaceholder: string;
optionValuePlaceholder: string;
optionImageSourceLabel: string;
optionImageSourceOptionUrl: string;
optionImageSourceOptionUpload: string;
optionImageUrlPlaceholder: string;
optionImageUploadLabel: string;
optionImageUploadAria: string;
groupTitleImageSourceLabel: string;
groupTitleImageSourceOptionUrl: string;
groupTitleImageSourceOptionUpload: string;
groupTitleImageUrlLabel: string;
groupTitleImageUploadLabel: string;
groupTitleImageUploadAria: string;
requiredNoticeText: string;
styleSectionTitle: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
optionGapLabel: string;
optionGapUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_RADIO_PANEL_MESSAGES: Record<AppLocale, EditorFormRadioPanelMessages> = {
en: {
sectionTitle: "Radio field",
groupTitleTypeLabel: "Group title type",
groupTitleTypeOptionText: "Text",
groupTitleTypeOptionImage: "Image",
groupTitleDisplayLabel: "Group title display mode",
groupTitleDisplayOptionVisible: "Visible (default)",
groupTitleDisplayOptionHidden: "Hidden",
layoutLabel: "Group title layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
optionLayoutLabel: "Option layout",
optionLayoutOptionStacked: "Vertical (default)",
optionLayoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap",
groupTitleLabel: "Group title",
submitKeyLabel: "Submit key",
optionsLabel: "Options",
addOptionButtonLabel: "Add option",
optionLabelPlaceholder: "Label",
optionValuePlaceholder: "Value (value)",
optionImageSourceLabel: "Option image source",
optionImageSourceOptionUrl: "URL",
optionImageSourceOptionUpload: "File upload",
optionImageUrlPlaceholder: "Option image URL (optional)",
optionImageUploadLabel: "Option image file upload",
optionImageUploadAria: "Option image file upload",
groupTitleImageSourceLabel: "Group title image source",
groupTitleImageSourceOptionUrl: "URL",
groupTitleImageSourceOptionUpload: "File upload",
groupTitleImageUrlLabel: "Group title image URL",
groupTitleImageUploadLabel: "Group title image file upload",
groupTitleImageUploadAria: "Group title image file upload",
requiredNoticeText: "Required state is configured in the form controller.",
styleSectionTitle: "Field style",
textSizeLabel: "Radio text size",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Radio line height",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Radio letter spacing",
letterSpacingUnitLabel: "(px)",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Radio horizontal padding",
paddingXUnitLabel: "(px)",
paddingYLabel: "Radio vertical padding",
paddingYUnitLabel: "(px)",
optionGapLabel: "Radio option gap",
optionGapUnitLabel: "(px)",
textColorLabel: "Text color",
textColorPickerAria: "Radio text color picker",
textColorHexAria: "Radio text color HEX",
fillColorLabel: "Background color",
fillColorPickerAria: "Radio background color picker",
fillColorHexAria: "Radio background color HEX",
strokeColorLabel: "Border color",
strokeColorPickerAria: "Radio border color picker",
strokeColorHexAria: "Radio border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "라디오 필드",
groupTitleTypeLabel: "그룹 타이틀 타입",
groupTitleTypeOptionText: "텍스트",
groupTitleTypeOptionImage: "이미지",
groupTitleDisplayLabel: "그룹 타이틀 표시 방식",
groupTitleDisplayOptionVisible: "표시 (기본)",
groupTitleDisplayOptionHidden: "숨김",
layoutLabel: "그룹 타이틀 레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
optionLayoutLabel: "옵션 레이아웃",
optionLayoutOptionStacked: "세로 (기본)",
optionLayoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격",
groupTitleLabel: "그룹 타이틀",
submitKeyLabel: "전송 키",
optionsLabel: "옵션",
addOptionButtonLabel: "옵션 추가",
optionLabelPlaceholder: "라벨",
optionValuePlaceholder: "값(value)",
optionImageSourceLabel: "옵션 이미지 소스",
optionImageSourceOptionUrl: "URL",
optionImageSourceOptionUpload: "파일 업로드",
optionImageUrlPlaceholder: "옵션 이미지 URL (선택)",
optionImageUploadLabel: "옵션 이미지 파일 업로드",
optionImageUploadAria: "옵션 이미지 파일 업로드",
groupTitleImageSourceLabel: "그룹 타이틀 이미지 소스",
groupTitleImageSourceOptionUrl: "URL",
groupTitleImageSourceOptionUpload: "파일 업로드",
groupTitleImageUrlLabel: "그룹 타이틀 이미지 URL",
groupTitleImageUploadLabel: "그룹 타이틀 이미지 파일 업로드",
groupTitleImageUploadAria: "그룹 타이틀 이미지 파일 업로드",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
styleSectionTitle: "필드 스타일",
textSizeLabel: "라디오 텍스트 크기",
textSizeUnitLabel: "(px)",
lineHeightLabel: "라디오 줄간격",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "라디오 자간",
letterSpacingUnitLabel: "(px)",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "라디오 가로 패딩",
paddingXUnitLabel: "(px)",
paddingYLabel: "라디오 세로 패딩",
paddingYUnitLabel: "(px)",
optionGapLabel: "라디오 옵션 간격",
optionGapUnitLabel: "(px)",
textColorLabel: "텍스트 색상",
textColorPickerAria: "라디오 텍스트 색상 피커",
textColorHexAria: "라디오 텍스트 색상 HEX",
fillColorLabel: "배경 색상",
fillColorPickerAria: "라디오 배경 색상 피커",
fillColorHexAria: "라디오 배경 색상 HEX",
strokeColorLabel: "테두리 색상",
strokeColorPickerAria: "라디오 테두리 색상 피커",
strokeColorHexAria: "라디오 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormRadioPanelMessages(locale: AppLocale | null | undefined): EditorFormRadioPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_RADIO_PANEL_MESSAGES[key] ?? EDITOR_FORM_RADIO_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,226 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorFormSelectPanelMessages = {
sectionTitle: string;
labelTypeLabel: string;
labelTypeOptionText: string;
labelTypeOptionImage: string;
labelDisplayLabel: string;
labelDisplayOptionVisible: string;
labelDisplayOptionHidden: string;
layoutLabel: string;
layoutOptionStacked: string;
layoutOptionInline: string;
labelGapLabel: string;
fieldLabelLabel: string;
submitKeyLabel: string;
optionsLabel: string;
addOptionButtonLabel: string;
optionLabelPlaceholder: string;
optionValuePlaceholder: string;
requiredNoticeText: string;
styleSectionTitle: string;
textSizeLabel: string;
textSizeUnitLabel: string;
lineHeightLabel: string;
lineHeightUnitLabel: string;
letterSpacingLabel: string;
letterSpacingUnitLabel: string;
widthModeLabel: string;
widthModeOptionAuto: string;
widthModeOptionFull: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
paddingXLabel: string;
paddingXUnitLabel: string;
paddingYLabel: string;
paddingYUnitLabel: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
fillColorLabel: string;
fillColorPickerAria: string;
fillColorHexAria: string;
strokeColorLabel: string;
strokeColorPickerAria: string;
strokeColorHexAria: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_FORM_SELECT_PANEL_MESSAGES: Record<AppLocale, EditorFormSelectPanelMessages> = {
en: {
sectionTitle: "Select field",
labelTypeLabel: "Label type",
labelTypeOptionText: "Text",
labelTypeOptionImage: "Image",
labelDisplayLabel: "Label display mode",
labelDisplayOptionVisible: "Visible (default)",
labelDisplayOptionHidden: "Hidden",
layoutLabel: "Layout",
layoutOptionStacked: "Vertical (default)",
layoutOptionInline: "Horizontal (inline)",
labelGapLabel: "Label/field gap",
fieldLabelLabel: "Field label",
submitKeyLabel: "Submit key",
optionsLabel: "Options",
addOptionButtonLabel: "Add option",
optionLabelPlaceholder: "Label",
optionValuePlaceholder: "Value (value)",
requiredNoticeText: "Required state is configured in the form controller.",
styleSectionTitle: "Field style",
textSizeLabel: "Select text size",
textSizeUnitLabel: "(px)",
lineHeightLabel: "Select line height",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "Select letter spacing",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "Field width",
widthModeOptionAuto: "Auto",
widthModeOptionFull: "Full width",
widthModeOptionFixed: "Fixed value",
fixedWidthLabel: "Field fixed width",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "Select horizontal padding",
paddingXUnitLabel: "(px)",
paddingYLabel: "Select vertical padding",
paddingYUnitLabel: "(px)",
textColorLabel: "Field text color",
textColorPickerAria: "Select text color picker",
textColorHexAria: "Select text color HEX",
fillColorLabel: "Field fill color",
fillColorPickerAria: "Select fill color picker",
fillColorHexAria: "Select fill color HEX",
strokeColorLabel: "Field border color",
strokeColorPickerAria: "Select border color picker",
strokeColorHexAria: "Select border color HEX",
borderRadiusLabel: "Field border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
sectionTitle: "셀렉트 필드",
labelTypeLabel: "라벨 타입",
labelTypeOptionText: "텍스트",
labelTypeOptionImage: "이미지",
labelDisplayLabel: "라벨 표시 방식",
labelDisplayOptionVisible: "표시 (기본)",
labelDisplayOptionHidden: "숨김",
layoutLabel: "레이아웃",
layoutOptionStacked: "세로 (기본)",
layoutOptionInline: "가로 (인라인)",
labelGapLabel: "라벨/필드 간격",
fieldLabelLabel: "필드 라벨",
submitKeyLabel: "전송 키",
optionsLabel: "옵션",
addOptionButtonLabel: "옵션 추가",
optionLabelPlaceholder: "라벨",
optionValuePlaceholder: "값(value)",
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
styleSectionTitle: "필드 스타일",
textSizeLabel: "셀렉트 텍스트 크기",
textSizeUnitLabel: "(px)",
lineHeightLabel: "셀렉트 줄간격",
lineHeightUnitLabel: "(px)",
letterSpacingLabel: "셀렉트 자간",
letterSpacingUnitLabel: "(px)",
widthModeLabel: "필드 너비",
widthModeOptionAuto: "자동",
widthModeOptionFull: "전체 폭",
widthModeOptionFixed: "고정 값",
fixedWidthLabel: "필드 고정 너비",
fixedWidthUnitLabel: "(px)",
paddingXLabel: "셀렉트 가로 패딩",
paddingXUnitLabel: "(px)",
paddingYLabel: "셀렉트 세로 패딩",
paddingYUnitLabel: "(px)",
textColorLabel: "필드 텍스트 색상",
textColorPickerAria: "셀렉트 텍스트 색상 피커",
textColorHexAria: "셀렉트 텍스트 색상 HEX",
fillColorLabel: "필드 채움 색상",
fillColorPickerAria: "셀렉트 채움 색상 피커",
fillColorHexAria: "셀렉트 채움 색상 HEX",
strokeColorLabel: "필드 테두리 색상",
strokeColorPickerAria: "셀렉트 테두리 색상 피커",
strokeColorHexAria: "셀렉트 테두리 색상 HEX",
borderRadiusLabel: "필드 모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorFormSelectPanelMessages(
locale: AppLocale | null | undefined,
): EditorFormSelectPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_FORM_SELECT_PANEL_MESSAGES[key] ?? EDITOR_FORM_SELECT_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,148 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorImagePanelMessages = {
imageSourceLabel: string;
imageSourceAria: string;
imageSourceOptionUrl: string;
imageSourceOptionUpload: string;
imageUrlLabel: string;
imageUrlAria: string;
imageUploadLabel: string;
imageUploadAria: string;
altLabel: string;
altAria: string;
styleSectionTitle: string;
cardBackgroundLabel: string;
cardBackgroundPickerAria: string;
cardBackgroundHexAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
widthModeLabel: string;
widthModeAria: string;
widthModeOptionAuto: string;
widthModeOptionFixed: string;
fixedWidthLabel: string;
fixedWidthUnitLabel: string;
fixedWidthPresetSmall: string;
fixedWidthPresetMedium: string;
fixedWidthPresetLarge: string;
borderRadiusLabel: string;
borderRadiusPresetNone: string;
borderRadiusPresetSmall: string;
borderRadiusPresetMedium: string;
borderRadiusPresetLarge: string;
borderRadiusPresetFull: string;
};
const EDITOR_IMAGE_PANEL_MESSAGES: Record<AppLocale, EditorImagePanelMessages> = {
en: {
imageSourceLabel: "Image source",
imageSourceAria: "Image source",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "File upload",
imageUrlLabel: "Image URL",
imageUrlAria: "Image URL",
imageUploadLabel: "Image file upload",
imageUploadAria: "Image file upload",
altLabel: "Alt text",
altAria: "Alt text",
styleSectionTitle: "Image style",
cardBackgroundLabel: "Card background color",
cardBackgroundPickerAria: "Image card background color picker",
cardBackgroundHexAria: "Image card background color HEX",
alignLabel: "Alignment",
alignAria: "Image alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
widthModeLabel: "Width mode",
widthModeAria: "Image width mode",
widthModeOptionAuto: "Fit to content",
widthModeOptionFixed: "Fixed width",
fixedWidthLabel: "Fixed width",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "Small",
fixedWidthPresetMedium: "Medium",
fixedWidthPresetLarge: "Wide",
borderRadiusLabel: "Border radius",
borderRadiusPresetNone: "None",
borderRadiusPresetSmall: "Small",
borderRadiusPresetMedium: "Medium",
borderRadiusPresetLarge: "Large",
borderRadiusPresetFull: "Fully rounded",
},
ko: {
imageSourceLabel: "이미지 소스",
imageSourceAria: "이미지 소스",
imageSourceOptionUrl: "URL",
imageSourceOptionUpload: "파일 업로드",
imageUrlLabel: "이미지 URL",
imageUrlAria: "이미지 URL",
imageUploadLabel: "이미지 파일 업로드",
imageUploadAria: "이미지 파일 업로드",
altLabel: "대체 텍스트",
altAria: "대체 텍스트",
styleSectionTitle: "이미지 스타일",
cardBackgroundLabel: "카드 배경색",
cardBackgroundPickerAria: "이미지 카드 배경색 피커",
cardBackgroundHexAria: "이미지 카드 배경색 HEX",
alignLabel: "정렬",
alignAria: "이미지 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
widthModeLabel: "너비 모드",
widthModeAria: "이미지 너비 모드",
widthModeOptionAuto: "내용에 맞춤",
widthModeOptionFixed: "고정 너비",
fixedWidthLabel: "고정 너비",
fixedWidthUnitLabel: "(px)",
fixedWidthPresetSmall: "작게",
fixedWidthPresetMedium: "보통",
fixedWidthPresetLarge: "넓게",
borderRadiusLabel: "모서리 둥글기",
borderRadiusPresetNone: "없음",
borderRadiusPresetSmall: "작게",
borderRadiusPresetMedium: "보통",
borderRadiusPresetLarge: "크게",
borderRadiusPresetFull: "완전 둥글게",
},
};
export function getEditorImagePanelMessages(
locale: AppLocale | null | undefined,
): EditorImagePanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_IMAGE_PANEL_MESSAGES[key] ?? EDITOR_IMAGE_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,160 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorListPanelMessages = {
listItemsLabel: string;
listItemsAria: string;
alignLabel: string;
alignAria: string;
alignOptionLeft: string;
alignOptionCenter: string;
alignOptionRight: string;
styleSectionTitle: string;
fontSizeLabel: string;
fontSizeUnitLabel: string;
fontSizePresetSmall: string;
fontSizePresetMedium: string;
fontSizePresetLarge: string;
lineHeightLabel: string;
lineHeightPresetTight: string;
lineHeightPresetNormal: string;
lineHeightPresetRelaxed: string;
textColorLabel: string;
textColorPickerAria: string;
textColorHexAria: string;
backgroundColorLabel: string;
backgroundColorPickerAria: string;
backgroundColorHexAria: string;
bulletStyleLabel: string;
bulletStyleAria: string;
bulletStyleOptionDisc: string;
bulletStyleOptionCircle: string;
bulletStyleOptionSquare: string;
bulletStyleOptionDecimal: string;
bulletStyleOptionLowerAlpha: string;
bulletStyleOptionUpperAlpha: string;
bulletStyleOptionLowerRoman: string;
bulletStyleOptionUpperRoman: string;
bulletStyleOptionNone: string;
gapLabel: string;
gapUnitLabel: string;
gapPresetTight: string;
gapPresetNormal: string;
gapPresetRelaxed: string;
};
const EDITOR_LIST_PANEL_MESSAGES: Record<AppLocale, EditorListPanelMessages> = {
en: {
listItemsLabel: "List items (separated by line breaks)",
listItemsAria: "List items",
alignLabel: "Alignment",
alignAria: "List alignment",
alignOptionLeft: "Left",
alignOptionCenter: "Center",
alignOptionRight: "Right",
styleSectionTitle: "List style",
fontSizeLabel: "Font size",
fontSizeUnitLabel: "(px)",
fontSizePresetSmall: "Small",
fontSizePresetMedium: "Medium",
fontSizePresetLarge: "Large",
lineHeightLabel: "Line height",
lineHeightPresetTight: "Tight",
lineHeightPresetNormal: "Normal",
lineHeightPresetRelaxed: "Relaxed",
textColorLabel: "Text color",
textColorPickerAria: "List text color picker",
textColorHexAria: "List text color HEX",
backgroundColorLabel: "Block background color",
backgroundColorPickerAria: "List background color picker",
backgroundColorHexAria: "List background color HEX",
bulletStyleLabel: "Bullet style",
bulletStyleAria: "List bullet style",
bulletStyleOptionDisc: "Default (●)",
bulletStyleOptionCircle: "Circle (○)",
bulletStyleOptionSquare: "Square (■)",
bulletStyleOptionDecimal: "Decimal (1.)",
bulletStyleOptionLowerAlpha: "Lower alpha (a.)",
bulletStyleOptionUpperAlpha: "Upper alpha (A.)",
bulletStyleOptionLowerRoman: "Lower roman (i.)",
bulletStyleOptionUpperRoman: "Upper roman (I.)",
bulletStyleOptionNone: "None",
gapLabel: "Item gap",
gapUnitLabel: "(px)",
gapPresetTight: "Tight",
gapPresetNormal: "Normal",
gapPresetRelaxed: "Relaxed",
},
ko: {
listItemsLabel: "리스트 아이템 (줄바꿈으로 구분)",
listItemsAria: "리스트 아이템들",
alignLabel: "정렬",
alignAria: "리스트 정렬",
alignOptionLeft: "왼쪽",
alignOptionCenter: "가운데",
alignOptionRight: "오른쪽",
styleSectionTitle: "리스트 스타일",
fontSizeLabel: "글자 크기",
fontSizeUnitLabel: "(px)",
fontSizePresetSmall: "작게",
fontSizePresetMedium: "보통",
fontSizePresetLarge: "크게",
lineHeightLabel: "줄 간격",
lineHeightPresetTight: "좁게",
lineHeightPresetNormal: "보통",
lineHeightPresetRelaxed: "넓게",
textColorLabel: "텍스트 색상",
textColorPickerAria: "리스트 텍스트 색상 피커",
textColorHexAria: "리스트 텍스트 색상 HEX",
backgroundColorLabel: "블록 배경색",
backgroundColorPickerAria: "리스트 배경색 피커",
backgroundColorHexAria: "리스트 배경색 HEX",
bulletStyleLabel: "불릿 스타일",
bulletStyleAria: "리스트 불릿 스타일",
bulletStyleOptionDisc: "기본 (●)",
bulletStyleOptionCircle: "원 (○)",
bulletStyleOptionSquare: "사각 (■)",
bulletStyleOptionDecimal: "숫자 (1.)",
bulletStyleOptionLowerAlpha: "알파벳 소문자 (a.)",
bulletStyleOptionUpperAlpha: "알파벳 대문자 (A.)",
bulletStyleOptionLowerRoman: "로마 숫자 소문자 (i.)",
bulletStyleOptionUpperRoman: "로마 숫자 대문자 (I.)",
bulletStyleOptionNone: "없음",
gapLabel: "아이템 간 여백",
gapUnitLabel: "(px)",
gapPresetTight: "좁게",
gapPresetNormal: "보통",
gapPresetRelaxed: "넓게",
},
};
export function getEditorListPanelMessages(
locale: AppLocale | null | undefined,
): EditorListPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_LIST_PANEL_MESSAGES[key] ?? EDITOR_LIST_PANEL_MESSAGES[DEFAULT_LOCALE];
}
@@ -0,0 +1,170 @@
import type { AppLocale } from "../locale";
import { DEFAULT_LOCALE } from "../locale";
export type EditorProjectPropertiesPanelMessages = {
sectionTitle: string;
projectTitleLabel: string;
projectTitleAria: string;
projectSlugLabel: string;
projectSlugAria: string;
canvasWidthLabel: string;
canvasWidthUnitLabel: string;
canvasWidthPresetMobile: string;
canvasWidthPresetTablet: string;
canvasWidthPresetDesktop: string;
canvasBgLabel: string;
canvasBgColorAria: string;
canvasBgHexAria: string;
pageBgLabel: string;
pageBgColorAria: string;
pageBgHexAria: string;
seoSectionTitle: string;
seoTitleLabel: string;
seoTitleAria: string;
seoTitlePlaceholderFallback: string;
seoDescriptionLabel: string;
seoDescriptionAria: string;
seoDescriptionPlaceholder: string;
seoOgImageUrlLabel: string;
seoOgImageUrlAria: string;
seoOgImageUrlPlaceholder: string;
seoCanonicalUrlLabel: string;
seoCanonicalUrlAria: string;
seoCanonicalUrlPlaceholder: string;
seoNoIndexAria: string;
seoNoIndexLabel: string;
headHtmlLabel: string;
headHtmlAria: string;
headHtmlPlaceholder: string;
trackingScriptLabel: string;
trackingScriptAria: string;
trackingScriptPlaceholder: string;
};
const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorProjectPropertiesPanelMessages> = {
en: {
sectionTitle: "Project settings",
projectTitleLabel: "Project title",
projectTitleAria: "Project title",
projectSlugLabel: "Project slug",
projectSlugAria: "Project slug",
canvasWidthLabel: "Canvas width",
canvasWidthUnitLabel: "(px)",
canvasWidthPresetMobile: "Mobile (390px)",
canvasWidthPresetTablet: "Tablet (768px)",
canvasWidthPresetDesktop: "Desktop (1200px)",
canvasBgLabel: "Canvas background color",
canvasBgColorAria: "Canvas background color",
canvasBgHexAria: "Canvas background HEX",
pageBgLabel: "Page background color",
pageBgColorAria: "Page background color",
pageBgHexAria: "Page background HEX",
seoSectionTitle: "SEO / meta",
seoTitleLabel: "SEO title",
seoTitleAria: "SEO title",
seoTitlePlaceholderFallback: "Page title",
seoDescriptionLabel: "Meta description",
seoDescriptionAria: "Meta description",
seoDescriptionPlaceholder: "Enter a description for search engines and social sharing.",
seoOgImageUrlLabel: "OG/Twitter image URL",
seoOgImageUrlAria: "OG/Twitter image URL",
seoOgImageUrlPlaceholder: "e.g. https://example.com/og-image.png",
seoCanonicalUrlLabel: "Canonical URL",
seoCanonicalUrlAria: "Canonical URL",
seoCanonicalUrlPlaceholder: "e.g. https://example.com/landing",
seoNoIndexAria: "Hide from search engines (noindex)",
seoNoIndexLabel: "Hide from search engines (noindex)",
headHtmlLabel: "Page head HTML",
headHtmlAria: "Page head HTML",
headHtmlPlaceholder: 'e.g. <meta name="description" content="..." />',
trackingScriptLabel: "Tracking script",
trackingScriptAria: "Tracking script",
trackingScriptPlaceholder: 'e.g. <script>/* GA, Pixel code */</script>',
},
ko: {
sectionTitle: "프로젝트 설정",
projectTitleLabel: "프로젝트 제목",
projectTitleAria: "프로젝트 제목",
projectSlugLabel: "프로젝트 주소 (slug)",
projectSlugAria: "프로젝트 주소 (slug)",
canvasWidthLabel: "캔버스 너비",
canvasWidthUnitLabel: "(px)",
canvasWidthPresetMobile: "모바일 (390px)",
canvasWidthPresetTablet: "태블릿 (768px)",
canvasWidthPresetDesktop: "데스크톱 (1200px)",
canvasBgLabel: "캔버스 배경색",
canvasBgColorAria: "캔버스 배경색",
canvasBgHexAria: "캔버스 배경색 HEX",
pageBgLabel: "페이지 배경색",
pageBgColorAria: "페이지 배경색",
pageBgHexAria: "페이지 배경색 HEX",
seoSectionTitle: "SEO / 메타",
seoTitleLabel: "SEO 타이틀",
seoTitleAria: "SEO 타이틀",
seoTitlePlaceholderFallback: "페이지 제목",
seoDescriptionLabel: "메타 디스크립션",
seoDescriptionAria: "메타 디스크립션",
seoDescriptionPlaceholder: "검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요.",
seoOgImageUrlLabel: "OG/Twitter 이미지 URL",
seoOgImageUrlAria: "OG/Twitter 이미지 URL",
seoOgImageUrlPlaceholder: "예: https://example.com/og-image.png",
seoCanonicalUrlLabel: "Canonical URL",
seoCanonicalUrlAria: "Canonical URL",
seoCanonicalUrlPlaceholder: "예: https://example.com/landing",
seoNoIndexAria: "검색 엔진에 노출하지 않기 (noindex)",
seoNoIndexLabel: "검색 엔진에 노출하지 않기 (noindex)",
headHtmlLabel: "페이지 head HTML",
headHtmlAria: "페이지 head HTML",
headHtmlPlaceholder: '예: <meta name="description" content="..." />',
trackingScriptLabel: "추적 스크립트",
trackingScriptAria: "추적 스크립트",
trackingScriptPlaceholder: '예: <script>/* GA, Pixel 코드 */</script>',
},
};
export function getEditorProjectPropertiesPanelMessages(
locale: AppLocale | null | undefined,
): EditorProjectPropertiesPanelMessages {
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
return EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES[key] ?? EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES[DEFAULT_LOCALE];
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More