Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce33ebf9b0 | |||
| 26ef92d0a3 | |||
| 4840a530b6 | |||
| 6804665b95 | |||
| 017d5f128e | |||
| 86f28a1299 | |||
| f71207aeb5 | |||
| 73e9bc6a1c | |||
| 9c07756e42 | |||
| e615aa1227 | |||
| 23a08621db | |||
| 676e58cad7 | |||
| 3413c3e689 | |||
| 9d8c4538c7 | |||
| b40be693ee | |||
| da3d71d124 | |||
| 4bb4c83054 | |||
| 243083261f | |||
| 5e8d0f4921 | |||
| 96fa34cd86 | |||
| 39ca7769fa | |||
| eb78359b10 | |||
| 72b027a29c | |||
| a385ed5de3 | |||
| 4902a0b5a9 | |||
| a5b432fb7b | |||
| e726f43f7c | |||
| 7574d3a72c | |||
| 3d683d99a5 | |||
| f087ae5f2c | |||
| 7cd9d1273b | |||
| 5832b64d39 | |||
| bd738d4bc0 | |||
| c331d5e14a | |||
| 3e223a45d4 | |||
| d0766fca45 | |||
| 555d00e060 | |||
| c3de7df252 | |||
| 9e75989c5d | |||
| dbe76503d9 | |||
| ed093bf04f | |||
| a5e846a1e8 | |||
| 198acd8db1 | |||
| 5414db8e49 | |||
| 44869b7514 | |||
| 6ad731b6e2 | |||
| eba4b0eeee | |||
| 5f541e9524 | |||
| 1d9548862a | |||
| 64d2e9d1af | |||
| 637d834cd7 | |||
| 64e4a59244 | |||
| 25162e4c12 | |||
| a6004a5caf | |||
| 23392d6ac2 | |||
| c0ddb39562 | |||
| 73f42078b7 | |||
| 278195772a | |||
| 0cf67b5619 | |||
| 17bfac62ed | |||
| 1f085bd64c | |||
| c9a62d479c | |||
| c7b6097bd9 | |||
| 6e5e229ae5 | |||
| af84bcbe8d | |||
| 55906f9d3d | |||
| 23bc96b3a1 | |||
| d4cf2f0225 | |||
| c5c9d17e8b | |||
| 1405280ed3 |
+44
-23
@@ -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
|
||||
|
||||
@@ -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/
|
||||
Generated
+145
-2
@@ -12,8 +12,11 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -25,6 +28,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
@@ -2788,6 +2793,13 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -2827,6 +2839,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jszip": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
||||
@@ -2837,6 +2860,13 @@
|
||||
"jszip": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
@@ -3923,6 +3953,12 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -3991,6 +4027,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -4572,6 +4614,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.18.4",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||
@@ -6638,6 +6689,28 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -6666,6 +6739,27 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7039,6 +7133,42 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -7046,6 +7176,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
@@ -7089,6 +7225,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
||||
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -7201,7 +7346,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
@@ -8332,7 +8476,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// /api/auth/logout
|
||||
// - 클라이언트의 pb_access 세션 쿠키를 제거해 로그아웃을 수행한다.
|
||||
|
||||
export async function POST(_request: Request) {
|
||||
const res = NextResponse.json({ message: "로그아웃 되었습니다." }, { status: 200 });
|
||||
|
||||
// pb_access 쿠키를 즉시 만료시켜 로그아웃 상태로 만든다.
|
||||
res.cookies.set("pb_access", "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/me
|
||||
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
|
||||
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
|
||||
return NextResponse.json(user, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
// Next.js 15 이후 params 가 Promise 로 전달되는 경고를 피하기 위해,
|
||||
// 동적 세그먼트는 request.url 의 path 에서 직접 파싱한다.
|
||||
let id: string | undefined;
|
||||
|
||||
export async function GET(request: Request, ctx: Params) {
|
||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||
let id: string | undefined = ctx?.params?.id;
|
||||
if (!id) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "image", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "image") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "image", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "image") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
|
||||
@@ -2,19 +2,21 @@ import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// 업로드된 이미지 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,24 +1,100 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
slug: string;
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { slug } = params;
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트를 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await context.params;
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(project, { status: 200 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트를 삭제하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await context.params;
|
||||
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.project.delete({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 });
|
||||
} catch (error: any) {
|
||||
// Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다.
|
||||
if (error?.code === "P2025") {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 범위를 토큰으로 매핑한다.
|
||||
|
||||
@@ -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="예: <meta name="description" content="..." />"
|
||||
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="예: <script>/* GA, Pixel 코드 */</script>"
|
||||
placeholder={m.trackingScriptPlaceholder}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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() !== "") {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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(" "),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { buildStaticHtml } from "@/app/api/export/route";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user