Compare commits

..

1 Commits

Author SHA1 Message Date
minheon 91c1a4abcd [feat] studio 페이지 생성 2026-04-02 14:00:47 +09:00
45 changed files with 2381 additions and 6 deletions

518
package-lock.json generated
View File

@ -8,8 +8,11 @@
"name": "fe",
"version": "0.0.0",
"dependencies": {
"@google/genai": "^1.48.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
@ -915,6 +918,29 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@google/genai": {
"version": "1.48.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz",
"integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^4.6.2",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1017,6 +1043,70 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@ -2244,7 +2334,6 @@
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@ -2270,6 +2359,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
@ -2609,6 +2704,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@ -2673,6 +2777,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
@ -2686,6 +2810,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@ -2731,6 +2864,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/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -2913,11 +3052,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -2982,6 +3129,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/electron-to-chromium": {
"version": "1.5.313",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
@ -3327,6 +3483,12 @@
"node": ">=0.10.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3366,6 +3528,29 @@
}
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -3453,6 +3638,45 @@
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3477,6 +3701,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -3557,6 +3809,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -3642,6 +3920,19 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3759,6 +4050,15 @@
"node": ">=6"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -3800,6 +4100,27 @@
"node": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"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": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3854,6 +4175,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -3874,6 +4201,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -3927,11 +4263,51 @@
"node": "*"
}
},
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.38.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"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/nanoid": {
@ -3971,6 +4347,44 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@ -4028,6 +4442,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4149,6 +4576,30 @@
"node": ">= 0.8.0"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -4244,6 +4695,15 @@
"node": ">=4"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@ -4289,6 +4749,26 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -4464,7 +4944,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
@ -4522,7 +5001,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@ -4671,6 +5149,15 @@
"vite": "*"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4697,6 +5184,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -10,8 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.48.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",

View File

@ -7,6 +7,7 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout";
import { Home } from "@/pages/Home";
import { PlanPage } from "@/pages/Plan";
import { ReportPage } from "@/pages/Report";
import { StudioPage } from "@/pages/Studio";
function App() {
@ -18,6 +19,7 @@ function App() {
<Route element={<MainSubNavLayout />}>
<Route path="report/:id" element={<ReportPage />} />
<Route path="plan/:id" element={<PlanPage />} />
<Route path="studio/:id" element={<StudioPage />} />
</Route>
</Routes>
)

View File

@ -0,0 +1,164 @@
/**
* Filled/Shape-style icons for Channel Strategy & Content Calendar.
* Soft pastel colors, no outlines all shapes use fill only.
*/
interface IconProps {
size?: number;
className?: string;
}
export function YoutubeFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="4" width="20" height="16" rx="4" fill="currentColor" opacity="0.25" />
<path d="M10 8.5v7l6-3.5-6-3.5z" fill="currentColor" />
</svg>
);
}
export function InstagramFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<circle cx="12" cy="12" r="4.5" fill="currentColor" />
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
</svg>
);
}
export function FacebookFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<path d="M15.5 3.5H13.5C11.29 3.5 9.5 5.29 9.5 7.5V9.5H7.5V12.5H9.5V20.5H12.5V12.5H14.5L15.5 9.5H12.5V7.5C12.5 7.22 12.72 7 13 7H15.5V3.5Z" fill="currentColor" />
</svg>
);
}
export function GlobeFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
<ellipse cx="12" cy="12" rx="4" ry="10" fill="currentColor" opacity="0.35" />
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
<line x1="12" y1="2" x2="12" y2="22" stroke="currentColor" strokeWidth="1.5" opacity="0.3" />
</svg>
);
}
export function VideoFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.25" />
<path d="M16 9.5L22 6.5V17.5L16 14.5V9.5Z" fill="currentColor" />
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.5" />
</svg>
);
}
export function MessageFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.3" />
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.4" />
</svg>
);
}
export function CalendarFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="4" width="18" height="18" rx="3" fill="currentColor" opacity="0.25" />
<rect x="3" y="4" width="18" height="6" rx="3" fill="currentColor" opacity="0.5" />
<circle cx="8" cy="15" r="1.2" fill="currentColor" />
<circle cx="12" cy="15" r="1.2" fill="currentColor" />
<circle cx="16" cy="15" r="1.2" fill="currentColor" />
<line x1="8" y1="2" x2="8" y2="5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<line x1="16" y1="2" x2="16" y2="5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function FileTextFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M6 2H14L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2Z" fill="currentColor" opacity="0.25" />
<path d="M14 2L20 8H16C14.9 8 14 7.1 14 6V2Z" fill="currentColor" opacity="0.5" />
<rect x="8" y="12" width="8" height="1.5" rx="0.75" fill="currentColor" />
<rect x="8" y="16" width="5" height="1.5" rx="0.75" fill="currentColor" />
</svg>
);
}
export function ShareFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="18" cy="5" r="3.5" fill="currentColor" opacity="0.4" />
<circle cx="6" cy="12" r="3.5" fill="currentColor" opacity="0.4" />
<circle cx="18" cy="19" r="3.5" fill="currentColor" opacity="0.4" />
<line x1="9" y1="10.5" x2="15" y2="6.5" stroke="currentColor" strokeWidth="2" opacity="0.3" />
<line x1="9" y1="13.5" x2="15" y2="17.5" stroke="currentColor" strokeWidth="2" opacity="0.3" />
</svg>
);
}
export function MegaphoneFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M19 3L8 8H4C2.9 8 2 8.9 2 10V14C2 15.1 2.9 16 4 16H5L7 21H10L8 16L19 21V3Z" fill="currentColor" opacity="0.35" />
<path d="M21 10C22.1 10 23 10.9 23 12C23 13.1 22.1 14 21 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
</svg>
);
}
export function TiktokFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<path d="M16.5 4.5V12.5C16.5 15.26 14.26 17.5 11.5 17.5C8.74 17.5 6.5 15.26 6.5 12.5C6.5 9.74 8.74 7.5 11.5 7.5V10C10.12 10 9 11.12 9 12.5C9 13.88 10.12 15 11.5 15C12.88 15 14 13.88 14 12.5V4.5H16.5Z" fill="currentColor" />
</svg>
);
}
export function MusicFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="7" cy="17" r="3" fill="currentColor" opacity="0.25" />
<circle cx="17" cy="15" r="3" fill="currentColor" opacity="0.25" />
<circle cx="7" cy="17" r="2" fill="currentColor" />
<circle cx="17" cy="15" r="2" fill="currentColor" />
<path d="M9 17V7L19 5V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<rect x="9" y="5" width="10" height="2" rx="1" fill="currentColor" opacity="0.4" />
</svg>
);
}
/**
* InfinityLoopFilled Infinite Marketing loop icon.
* HubSpot-style infinity loop with gradient shading.
* Horizontal aspect ratio, scaled to match text cap-height.
*/
export function PrismFilled({ size = 20, className = '' }: IconProps) {
const w = Math.round(size * 1.6);
const h = size;
const id = `inf-grad-${size}`;
return (
<svg width={w} height={h} viewBox="0 0 28 20" fill="none" className={className}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="28" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
<stop offset="45%" stopColor="currentColor" stopOpacity="0.55" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</linearGradient>
</defs>
<path
d="M2,10 C2,4 8,4 14,10 C20,16 26,16 26,10 C26,4 20,4 14,10 C8,16 2,16 2,10Z"
stroke={`url(#${id})`}
strokeWidth="2.8"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}

View File

@ -0,0 +1,33 @@
// ─── 위저드 스텝 플로우 정의 ────────────────────────────────────────────────
// naver_blog 채널은 BLOG_FLOW, 나머지 채널은 VIDEO_FLOW를 따름
import type { StudioChannel } from "@/features/studio/types/studio";
export type VideoStepKey = 'channel-format' | 'strategy-source' | 'sound' | 'generate';
export type BlogStepKey = 'channel-format' | 'strategy-source' | 'blog-editor';
export type StepKey = VideoStepKey | BlogStepKey;
export interface StepConfig {
key: StepKey;
label: string;
}
export const VIDEO_FLOW_STEPS: StepConfig[] = [
{ key: 'channel-format', label: '채널 선택' },
{ key: 'strategy-source', label: '전략 선택' },
{ key: 'sound', label: '사운드' },
{ key: 'generate', label: '생성' },
];
export const BLOG_FLOW_STEPS: StepConfig[] = [
{ key: 'channel-format', label: '채널 선택' },
{ key: 'strategy-source', label: '전략 선택' },
{ key: 'blog-editor', label: '콘텐츠 생성' },
];
export const BLOG_CHANNELS: StudioChannel[] = ['naver_blog'];
export function getStepsForChannel(channel: StudioChannel | null): StepConfig[] {
if (channel && BLOG_CHANNELS.includes(channel)) return BLOG_FLOW_STEPS;
return VIDEO_FLOW_STEPS;
}

View File

@ -0,0 +1,15 @@
/** 채널 & 포맷 선택 섹션 카피 */
export const CHANNEL_FORMAT_SECTION_TITLE = "채널 & 포맷 선택";
export const CHANNEL_FORMAT_SECTION_DESCRIPTION =
"콘텐츠를 게시할 채널과 포맷을 선택하세요";
export const CHANNEL_SELECT_LABEL = "채널";
export const FORMAT_SELECT_LABEL = "포맷";
export const ASPECT_RATIO_LABEL: Record<string, string> = {
"16:9": "가로형",
"9:16": "세로형",
"1:1": "정방형",
"4:5": "세로형",
};

View File

@ -0,0 +1,33 @@
/** 사운드 설정 섹션 카피 */
import type { MusicGenre, NarrationLanguage } from "@/features/studio/types/studio";
// ── 섹션 타이틀 & 설명 ───────────────────────────────────────────────────────
export const SOUND_MUSIC_TITLE = "배경 음악";
export const SOUND_MUSIC_DESCRIPTION = "콘텐츠에 어울리는 음악 장르를 선택하세요";
export const SOUND_NARRATION_TITLE = "나레이션";
export const SOUND_SUBTITLE_TITLE = "자막";
export const SOUND_SUBTITLE_ON_LABEL = "자막이 영상에 포함됩니다";
export const SOUND_SUBTITLE_OFF_LABEL = "자막 없음";
export const SOUND_LANG_LABEL = "언어";
export const SOUND_VOICE_LABEL = "보이스";
// ── 장르 옵션 ────────────────────────────────────────────────────────────────
export const GENRE_OPTIONS: { key: MusicGenre; label: string }[] = [
{ key: "calm", label: "Calm" },
{ key: "upbeat", label: "Upbeat" },
{ key: "cinematic", label: "Cinematic" },
{ key: "corporate", label: "Corporate" },
{ key: "none", label: "No Music" },
];
// ── 나레이션 언어 옵션 ────────────────────────────────────────────────────────
export const LANGUAGE_OPTIONS: { key: NarrationLanguage; label: string }[] = [
{ key: "ko", label: "한국어" },
{ key: "en", label: "English" },
{ key: "ja", label: "日本語" },
{ key: "zh", label: "中文" },
];

View File

@ -0,0 +1,7 @@
/** 콘텐츠 전략 & 소스 선택 섹션 카피 */
export const PILLAR_COLUMN_TITLE = "콘텐츠 전략";
export const PILLAR_COLUMN_DESCRIPTION = "콘텐츠의 핵심 메시지 필러를 선택하세요";
export const SOURCE_COLUMN_TITLE = "소스 선택";
export const SOURCE_COLUMN_DESCRIPTION = "콘텐츠에 사용할 에셋 소스를 선택하세요 (복수 선택)";

View File

@ -0,0 +1,120 @@
// ─── 스튜디오 위저드 상태 관리 훅 ───────────────────────────────────────────
// DEMO/src/components/studio/StudioWizard.tsx 의 상태 로직에서 이전
// TODO: Phase 2 — Zustand store 로 전환
import { useState } from "react";
import type { StudioState, StudioChannel, ContentFormat, ContentPillarId, AssetSourceType, SoundSettings, GenerateOutputType } from "@/features/studio/types/studio";
import { getStepsForChannel } from "@/features/studio/config/studioSteps";
const DEFAULT_SOUND: SoundSettings = {
genre: 'calm',
trackId: null,
narrationEnabled: false,
narrationLanguage: 'ko',
narrationVoice: 'female',
subtitleEnabled: true,
};
const INITIAL_STATE: StudioState = {
channel: null,
format: null,
pillarId: null,
assetSources: [],
sound: DEFAULT_SOUND,
outputType: 'video',
};
export function useStudioWizard() {
const [state, setState] = useState<StudioState>(INITIAL_STATE);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const steps = getStepsForChannel(state.channel);
const currentStep = steps[currentStepIndex];
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
// ── 스텝별 진행 가능 여부 ─────────────────────────────────────────────────
// TODO: 각 스텝 구현 후 세부 유효성 검사 추가
function canProceed(): boolean {
switch (currentStep.key) {
case 'channel-format': return state.channel !== null && state.format !== null;
case 'strategy-source': return state.pillarId !== null && state.assetSources.length > 0;
case 'sound': return true;
case 'generate': return true;
case 'blog-editor': return true;
default: return false;
}
}
// ── 스텝 이동 ─────────────────────────────────────────────────────────────
function goNext() {
if (!isLastStep && canProceed()) setCurrentStepIndex((i) => i + 1);
}
function goPrev() {
if (!isFirstStep) setCurrentStepIndex((i) => i - 1);
}
function goToStep(index: number) {
if (index < currentStepIndex) setCurrentStepIndex(index);
}
// ── 상태 업데이트 핸들러 ──────────────────────────────────────────────────
function setChannel(channel: StudioChannel) {
// 채널 변경 시 format 초기화 + blog 채널은 outputType을 image로 고정
setState((prev) => ({
...prev,
channel,
format: null,
outputType: channel === 'naver_blog' ? 'image' : 'video',
}));
setCurrentStepIndex(0);
}
function setFormat(format: ContentFormat) {
setState((prev) => ({ ...prev, format }));
}
function setPillar(pillarId: ContentPillarId) {
setState((prev) => ({ ...prev, pillarId }));
}
function toggleAssetSource(source: AssetSourceType) {
setState((prev) => {
const exists = prev.assetSources.includes(source);
return {
...prev,
assetSources: exists
? prev.assetSources.filter((s) => s !== source)
: [...prev.assetSources, source],
};
});
}
function setSound(sound: Partial<SoundSettings>) {
setState((prev) => ({ ...prev, sound: { ...prev.sound, ...sound } }));
}
function setOutputType(outputType: GenerateOutputType) {
setState((prev) => ({ ...prev, outputType }));
}
return {
state,
steps,
currentStep,
currentStepIndex,
isFirstStep,
isLastStep,
canProceed,
goNext,
goPrev,
goToStep,
setChannel,
setFormat,
setPillar,
toggleAssetSource,
setSound,
setOutputType,
};
}

View File

@ -0,0 +1,12 @@
import { createContext, useContext } from "react";
import type { useStudioWizard } from "@/features/studio/hooks/useStudioWizard";
export type StudioWizardContextValue = ReturnType<typeof useStudioWizard>;
export const StudioWizardContext = createContext<StudioWizardContextValue | null>(null);
export function useStudioWizardContext(): StudioWizardContextValue {
const ctx = useContext(StudioWizardContext);
if (!ctx) throw new Error("useStudioWizardContext must be used within StudioWizardProvider");
return ctx;
}

View File

@ -0,0 +1,53 @@
// ─── 채널 & 포맷 옵션 목 데이터 ─────────────────────────────────────────────
// DEMO/src/types/studio.ts CHANNEL_OPTIONS 에서 이전
import type { ChannelFormatOption } from "@/features/studio/types/studio";
export const MOCK_CHANNEL_OPTIONS: ChannelFormatOption[] = [
{
channel: 'youtube',
label: 'YouTube',
icon: 'youtube',
formats: [
{ key: 'shorts', label: 'Shorts', aspectRatio: '9:16' },
{ key: 'long_form', label: 'Long-form', aspectRatio: '16:9' },
],
},
{
channel: 'instagram',
label: 'Instagram',
icon: 'instagram',
formats: [
{ key: 'reels', label: 'Reels', aspectRatio: '9:16' },
{ key: 'carousel', label: 'Carousel', aspectRatio: '1:1' },
{ key: 'feed_image', label: 'Feed Image', aspectRatio: '1:1' },
{ key: 'stories', label: 'Stories', aspectRatio: '9:16' },
],
},
{
channel: 'naver_blog',
label: 'Naver Blog',
icon: 'globe',
formats: [
{ key: 'seo_post', label: 'SEO Post', aspectRatio: '16:9' },
{ key: 'faq_post', label: 'FAQ Post', aspectRatio: '16:9' },
],
},
{
channel: 'tiktok',
label: 'TikTok',
icon: 'tiktok',
formats: [
{ key: 'short_video', label: 'Short Video', aspectRatio: '9:16' },
],
},
{
channel: 'facebook',
label: 'Facebook',
icon: 'facebook',
formats: [
{ key: 'ad_creative', label: 'Ad Creative', aspectRatio: '1:1' },
{ key: 'retarget_content', label: 'Retarget Content', aspectRatio: '16:9' },
],
},
];

View File

@ -0,0 +1,31 @@
// ─── 콘텐츠 필라 목 데이터 ──────────────────────────────────────────────────
// DEMO/src/components/studio/StrategySourceStep.tsx PILLARS 에서 이전
import type { ContentPillar } from "@/features/studio/types/studio";
export const MOCK_CONTENT_PILLARS: ContentPillar[] = [
{
id: 'safety',
title: '안전과 신뢰',
description: '수술 안전 시스템, 21년 무사고, CCTV, 마취과 전문의 상주',
color: '#6C5CE7',
},
{
id: 'expertise',
title: '전문성과 기술력',
description: '분야별 전문의, 최신 장비, 논문 실적, 학회 활동',
color: '#7A84D4',
},
{
id: 'results',
title: '자연스러운 결과',
description: '비포/애프터, 환자 후기, 한번에 성공하는 성형',
color: '#9B8AD4',
},
{
id: 'care',
title: '환자 중심 케어',
description: '상담 프로세스, 사후 관리, 1:1 맞춤 플랜',
color: '#D4A872',
},
];

View File

@ -0,0 +1,19 @@
// ─── 음악 트랙 목 데이터 ────────────────────────────────────────────────────
// DEMO/src/types/studio.ts MUSIC_TRACKS 에서 이전
import type { MusicTrack } from "@/features/studio/types/studio";
export const MOCK_MUSIC_TRACKS: MusicTrack[] = [
{ id: 'calm-1', name: 'Gentle Morning', genre: 'calm', duration: '2:30' },
{ id: 'calm-2', name: 'Soft Healing', genre: 'calm', duration: '3:15' },
{ id: 'calm-3', name: 'Peaceful Flow', genre: 'calm', duration: '2:45' },
{ id: 'upbeat-1', name: 'Fresh Start', genre: 'upbeat', duration: '2:20' },
{ id: 'upbeat-2', name: 'Bright Day', genre: 'upbeat', duration: '2:50' },
{ id: 'upbeat-3', name: 'Energy Boost', genre: 'upbeat', duration: '3:00' },
{ id: 'cinematic-1', name: 'Grand Reveal', genre: 'cinematic', duration: '3:30' },
{ id: 'cinematic-2', name: 'Transformation', genre: 'cinematic', duration: '4:00' },
{ id: 'cinematic-3', name: 'Before & After', genre: 'cinematic', duration: '2:55' },
{ id: 'corp-1', name: 'Professional Trust', genre: 'corporate', duration: '2:40' },
{ id: 'corp-2', name: 'Clean & Modern', genre: 'corporate', duration: '3:10' },
{ id: 'corp-3', name: 'Confidence', genre: 'corporate', duration: '2:35' },
];

View File

@ -0,0 +1,85 @@
import { GoogleGenAI } from "@google/genai";
import type { StudioState } from "@/features/studio/types/studio";
import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels";
const client = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY ?? "" });
function buildPrompt(state: StudioState): string {
const channel = MOCK_CHANNEL_OPTIONS.find((c) => c.channel === state.channel);
const format = channel?.formats.find((f) => f.key === state.format);
const pillarDescriptions: Record<string, string> = {
safety: "hospital safety systems, clean surgical rooms, CCTV monitoring, professional medical team, trust and reliability",
expertise: "medical expertise, advanced surgical equipment, professional doctors, medical certifications, precision and skill",
results: "natural beautiful results, before and after transformation, patient satisfaction, subtle elegant outcomes",
care: "patient-centered care, warm consultation, personalized treatment plan, caring medical staff, comfortable clinic interior",
};
const pillarContext = state.pillarId ? (pillarDescriptions[state.pillarId] ?? "") : "";
const aspectRatio = format?.aspectRatio ?? "16:9";
const channelHints: Record<string, string> = {
youtube: "YouTube thumbnail style, bold text overlay area, high contrast, eye-catching",
instagram: "Instagram aesthetic, clean minimalist, lifestyle photography style, warm tones",
naver_blog: "Korean blog header image, informative, medical illustration, clean layout",
tiktok: "TikTok cover image, vertical format, trendy, dynamic, youthful",
facebook: "Facebook ad creative, professional, compelling, clear messaging space",
};
const channelHint = state.channel ? (channelHints[state.channel] ?? "") : "";
return [
"Generate a premium medical marketing image for a plastic surgery clinic.",
`Theme: ${pillarContext}`,
`Style: ${channelHint}`,
`Aspect ratio: ${aspectRatio}`,
"Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).",
"Premium, luxurious, trustworthy aesthetic.",
"No text or logos in the image.",
"Photorealistic, high quality, professional medical marketing.",
]
.filter(Boolean)
.join(" ");
}
export interface GenerateResult {
imageDataUrl: string;
prompt: string;
}
export async function generateImage(state: StudioState): Promise<GenerateResult> {
const prompt = buildPrompt(state);
let response;
try {
response = await client.models.generateContent({
model: "gemini-2.5-flash-image",
contents: prompt,
config: {
responseModalities: ["Text", "Image"],
},
});
} catch (err: unknown) {
const e = err as { status?: unknown; code?: unknown; message?: string };
const code = e?.status ?? e?.code ?? e?.message?.match(/"code":(\d+)/)?.[1];
if (String(code) === "429" || e?.message?.includes("RESOURCE_EXHAUSTED")) {
throw new Error("API 요청 한도 초과 — 잠시 후 다시 시도해주세요");
}
if (String(code) === "400" || e?.message?.includes("INVALID_ARGUMENT")) {
throw new Error("API 설정 오류 — 관리자에게 문의하세요");
}
throw new Error("이미지 생성 중 오류가 발생했습니다");
}
const parts = response.candidates?.[0]?.content?.parts;
if (!parts) throw new Error("AI 응답을 받지 못했습니다");
const imagePart = parts.find((p: { inlineData?: { mimeType?: string; data?: string } }) =>
p.inlineData?.mimeType?.startsWith("image/")
);
if (!imagePart?.inlineData) throw new Error("이미지가 생성되지 않았습니다 — 다시 시도해주세요");
const { mimeType, data } = imagePart.inlineData;
const imageDataUrl = `data:${mimeType};base64,${data}`;
return { imageDataUrl, prompt };
}

View File

@ -0,0 +1,66 @@
// ─── Content Studio Types ───────────────────────────────────────────────────
// DEMO/src/types/studio.ts 에서 이전
export type StudioChannel = 'youtube' | 'instagram' | 'naver_blog' | 'tiktok' | 'facebook';
export type ContentFormat =
| 'shorts' | 'long_form' // YouTube
| 'reels' | 'carousel' | 'feed_image' | 'stories' // Instagram
| 'seo_post' | 'faq_post' // Naver Blog
| 'short_video' // TikTok
| 'ad_creative' | 'retarget_content'; // Facebook
export interface ChannelFormatOption {
channel: StudioChannel;
label: string;
icon: string;
formats: {
key: ContentFormat;
label: string;
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5';
}[];
}
export type ContentPillarId = string;
export type AssetSourceType = 'collected' | 'my_assets' | 'ai_generated';
export type MusicGenre = 'calm' | 'upbeat' | 'cinematic' | 'corporate' | 'none';
export interface MusicTrack {
id: string;
name: string;
genre: MusicGenre;
duration: string;
}
export type NarrationLanguage = 'ko' | 'en' | 'ja' | 'zh';
export type NarrationVoice = 'male' | 'female';
export interface SoundSettings {
genre: MusicGenre;
trackId: string | null;
narrationEnabled: boolean;
narrationLanguage: NarrationLanguage;
narrationVoice: NarrationVoice;
subtitleEnabled: boolean;
}
export type GenerateOutputType = 'image' | 'video';
export interface StudioState {
channel: StudioChannel | null;
format: ContentFormat | null;
pillarId: ContentPillarId | null;
assetSources: AssetSourceType[];
sound: SoundSettings;
outputType: GenerateOutputType;
}
// ─── 콘텐츠 필라 타입 ───────────────────────────────────────────────────────
export interface ContentPillar {
id: ContentPillarId;
title: string;
description: string;
color: string;
}

View File

@ -0,0 +1,12 @@
// TODO: 5단계 리팩토링 — content/blogEditor.ts + blogEditor/ 하위 컴포넌트
export function StudioBlogEditorSection() {
return (
<section className="px-6 py-10">
<div className="max-w-3xl mx-auto">
{/* TODO: 5단계 구현 예정 */}
<p className="body-14 text-neutral-40">Blog Editor </p>
</div>
</section>
);
}

View File

@ -0,0 +1,27 @@
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels";
import { ChannelSelectGrid } from "@/features/studio/ui/channelFormat/ChannelSelectGrid";
import { FormatSelectGrid } from "@/features/studio/ui/channelFormat/FormatSelectGrid";
export function StudioChannelFormatSection() {
const { state, setChannel, setFormat } = useStudioWizardContext();
const selectedOption = MOCK_CHANNEL_OPTIONS.find((o) => o.channel === state.channel) ?? null;
return (
<div>
<ChannelSelectGrid
selectedChannel={state.channel}
onChannelSelect={setChannel}
/>
{selectedOption && (
<FormatSelectGrid
option={selectedOption}
selectedFormat={state.format}
onFormatSelect={setFormat}
/>
)}
</div>
);
}

View File

@ -0,0 +1,117 @@
import { useState, useCallback, useRef } from "react";
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels";
import { MOCK_MUSIC_TRACKS } from "@/features/studio/mocks/musicTracks";
import { generateImage, type GenerateResult } from "@/features/studio/services/generateImage";
import { OutputTypeTabs } from "@/features/studio/ui/generate/OutputTypeTabs";
import { SettingsSummary } from "@/features/studio/ui/generate/SettingsSummary";
import { PreviewArea } from "@/features/studio/ui/generate/PreviewArea";
type GenerateStatus = "idle" | "generating" | "done" | "error";
export function StudioGenerateSection() {
const { state, setOutputType } = useStudioWizardContext();
const [status, setStatus] = useState<GenerateStatus>("idle");
const [result, setResult] = useState<GenerateResult | null>(null);
const [errorMsg, setErrorMsg] = useState("");
const downloadRef = useRef<HTMLAnchorElement>(null);
const activeChannel = MOCK_CHANNEL_OPTIONS.find((c) => c.channel === state.channel);
const activeFormat = activeChannel?.formats.find((f) => f.key === state.format);
const activeTrack = MOCK_MUSIC_TRACKS.find((t) => t.id === state.sound.trackId);
const aspectClass =
activeFormat?.aspectRatio === "9:16" ? "w-[240px] h-[426px]" :
activeFormat?.aspectRatio === "1:1" ? "w-[340px] h-[340px]" :
activeFormat?.aspectRatio === "4:5" ? "w-[300px] h-[375px]" :
"w-[426px] h-[240px]";
const handleReset = useCallback(() => {
setStatus("idle");
setResult(null);
setErrorMsg("");
}, []);
const handleOutputTypeChange = useCallback(
(type: typeof state.outputType) => {
setOutputType(type);
handleReset();
},
[setOutputType, handleReset]
);
const handleGenerate = useCallback(async () => {
setStatus("generating");
setErrorMsg("");
setResult(null);
if (state.outputType === "image") {
try {
const res = await generateImage(state);
setResult(res);
setStatus("done");
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "이미지 생성 중 오류가 발생했습니다");
setStatus("error");
}
} else {
setTimeout(() => setStatus("done"), 4000);
}
}, [state]);
const handleDownload = useCallback(() => {
if (!result?.imageDataUrl || !downloadRef.current) return;
const link = downloadRef.current;
link.href = result.imageDataUrl;
link.download = `CLINCAD_${activeChannel?.label ?? "content"}_${activeFormat?.key ?? "image"}.png`;
link.click();
}, [result, activeChannel, activeFormat]);
const trackName = activeTrack?.name ?? (state.sound.genre === "none" ? "없음" : "미선택");
const narrationValue = state.sound.narrationEnabled
? `${state.sound.narrationLanguage.toUpperCase()} / ${state.sound.narrationVoice === "female" ? "Female" : "Male"}`
: "없음";
return (
<div>
{/* Hidden download anchor */}
<a ref={downloadRef} className="hidden" />
<OutputTypeTabs
outputType={state.outputType}
onOutputTypeChange={handleOutputTypeChange}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<SettingsSummary
channelLabel={activeChannel?.label ?? "-"}
formatLabel={activeFormat?.label ?? "-"}
aspectRatio={activeFormat?.aspectRatio ?? "-"}
trackName={trackName}
narrationValue={narrationValue}
subtitleValue={state.sound.subtitleEnabled ? "ON" : "OFF"}
outputType={state.outputType}
status={status}
result={result}
errorMsg={errorMsg}
onGenerate={handleGenerate}
onReset={handleReset}
onDownload={handleDownload}
/>
<PreviewArea
status={status}
result={result}
errorMsg={errorMsg}
outputType={state.outputType}
channelLabel={activeChannel?.label ?? ""}
formatLabel={activeFormat?.label ?? ""}
aspectRatio={activeFormat?.aspectRatio ?? ""}
aspectClass={aspectClass}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
import type { StepConfig } from "@/features/studio/config/studioSteps";
export function StudioProgressBar() {
const { steps, currentStepIndex, goToStep } = useStudioWizardContext();
return (
<div className="flex items-center justify-center">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center">
<button
onClick={() => goToStep(i)}
className={[
"flex items-center gap-2 transition-all",
i <= currentStepIndex ? "cursor-pointer" : "cursor-default",
].join(" ")}
>
{/* 스텝 번호 원 */}
<div className={[
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all",
i < currentStepIndex
? "bg-gradient-to-r from-violet-700 to-navy-950 text-white"
: i === currentStepIndex
? "border-2 border-violet-500 text-violet-500 bg-white"
: "border-2 border-neutral-30 text-neutral-40 bg-white",
].join(" ")}>
{i < currentStepIndex ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 7L5.5 10.5L12 3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
i + 1
)}
</div>
{/* 스텝 라벨 */}
<span className={[
"text-sm font-medium hidden md:inline",
i <= currentStepIndex ? "text-navy-900" : "text-neutral-40",
].join(" ")}>
{s.label}
</span>
</button>
{/* 연결 선 */}
{i < steps.length - 1 && (
<div className={[
"w-12 md:w-20 h-1 mx-2 transition-colors",
i < currentStepIndex ? "bg-violet-500" : "bg-neutral-30",
].join(" ")} />
)}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,72 @@
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
import { GenreChipList } from "@/features/studio/ui/sound/GenreChipList";
import { TrackSelectGrid } from "@/features/studio/ui/sound/TrackSelectGrid";
import { ToggleSwitch } from "@/features/studio/ui/sound/ToggleSwitch";
import { NarrationSettings } from "@/features/studio/ui/sound/NarrationSettings";
export function StudioSoundSection() {
const { state, setSound } = useStudioWizardContext();
const { sound } = state;
return (
<div className="space-y-10">
{/* ── 배경 음악 ─────────────────────────────────────────────────── */}
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-2 break-keep"> </h3>
<p className="text-sm text-neutral-60 mb-6 break-keep"> </p>
<div className="mb-6">
<GenreChipList
selectedGenre={sound.genre}
onGenreChange={(genre) => setSound({ genre, trackId: null })}
/>
</div>
<TrackSelectGrid
genre={sound.genre}
selectedTrackId={sound.trackId}
onTrackChange={(trackId) => setSound({ trackId })}
/>
</div>
{/* ── 나레이션 ─────────────────────────────────────────────────── */}
<div>
<div className="flex items-center gap-4 mb-6">
<h3 className="font-serif font-bold text-2xl text-navy-900 break-keep"></h3>
<ToggleSwitch
enabled={sound.narrationEnabled}
onToggle={(enabled) => setSound({ narrationEnabled: enabled })}
label="나레이션 토글"
/>
</div>
{sound.narrationEnabled && (
<NarrationSettings
language={sound.narrationLanguage}
voice={sound.narrationVoice}
onLanguageChange={(narrationLanguage) => setSound({ narrationLanguage })}
onVoiceChange={(narrationVoice) => setSound({ narrationVoice })}
/>
)}
</div>
{/* ── 자막 ──────────────────────────────────────────────────────── */}
<div>
<div className="flex items-center gap-4">
<h3 className="font-serif font-bold text-2xl text-navy-900 break-keep"></h3>
<ToggleSwitch
enabled={sound.subtitleEnabled}
onToggle={(enabled) => setSound({ subtitleEnabled: enabled })}
label="자막 토글"
/>
<span className="text-sm text-neutral-60 break-keep">
{sound.subtitleEnabled ? "자막이 영상에 포함됩니다" : "자막 없음"}
</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { AnimatePresence, motion } from "motion/react";
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
import { StudioChannelFormatSection } from "@/features/studio/ui/StudioChannelFormatSection";
import { StudioStrategySourceSection } from "@/features/studio/ui/StudioStrategySourceSection";
import { StudioSoundSection } from "@/features/studio/ui/StudioSoundSection";
import { StudioGenerateSection } from "@/features/studio/ui/StudioGenerateSection";
import { StudioBlogEditorSection } from "@/features/studio/ui/StudioBlogEditorSection";
export function StudioStepAnimator() {
const { currentStep, currentStepIndex } = useStudioWizardContext();
function renderStep() {
switch (currentStep.key) {
case "channel-format": return <StudioChannelFormatSection />;
case "strategy-source": return <StudioStrategySourceSection />;
case "sound": return <StudioSoundSection />;
case "generate": return <StudioGenerateSection />;
case "blog-editor": return <StudioBlogEditorSection />;
default: return null;
}
}
return (
<AnimatePresence mode="wait">
<motion.div
key={`${currentStep.key}-${currentStepIndex}`}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{renderStep()}
</motion.div>
</AnimatePresence>
);
}

View File

@ -0,0 +1,20 @@
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
import { PillarSelectList } from "@/features/studio/ui/strategySource/PillarSelectList";
import { SourceSelectList } from "@/features/studio/ui/strategySource/SourceSelectList";
export function StudioStrategySourceSection() {
const { state, setPillar, toggleAssetSource } = useStudioWizardContext();
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
<PillarSelectList
selectedPillarId={state.pillarId}
onPillarSelect={setPillar}
/>
<SourceSelectList
selectedSources={state.assetSources}
onSourceToggle={toggleAssetSource}
/>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
export function StudioWizardFooterSection() {
const { isFirstStep, isLastStep, canProceed, goNext, goPrev } = useStudioWizardContext();
return (
<div className="flex items-center justify-between mt-12">
<button
onClick={goPrev}
disabled={isFirstStep}
className="flex items-center gap-2 px-5 py-3 rounded-full text-sm font-medium text-neutral-70 bg-white border border-neutral-30 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-neutral-10 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
{!isLastStep ? (
<button
onClick={goNext}
disabled={!canProceed()}
className="flex items-center gap-2 px-6 py-3 rounded-full text-sm font-medium text-white bg-gradient-to-r from-violet-700 to-navy-950 shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
) : (
<div />
)}
</div>
);
}

View File

@ -0,0 +1,9 @@
import { StudioProgressBar } from "@/features/studio/ui/StudioProgressBar";
export function StudioWizardHeaderSection() {
return (
<div className="flex items-center justify-center mb-12">
<StudioProgressBar />
</div>
);
}

View File

@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { useStudioWizard } from "@/features/studio/hooks/useStudioWizard";
import { StudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext";
interface StudioWizardProviderProps {
children: ReactNode;
}
export function StudioWizardProvider({ children }: StudioWizardProviderProps) {
const wizard = useStudioWizard();
return (
<StudioWizardContext.Provider value={wizard}>
{children}
</StudioWizardContext.Provider>
);
}

View File

@ -0,0 +1,21 @@
interface AspectRatioPreviewProps {
aspectRatio: "16:9" | "9:16" | "1:1" | "4:5";
}
const SIZE_CLASSES: Record<string, string> = {
"9:16": "w-10 h-16",
"16:9": "w-16 h-10",
"4:5": "w-12 h-14",
"1:1": "w-12 h-12",
};
export function AspectRatioPreview({ aspectRatio }: AspectRatioPreviewProps) {
return (
<div className={[
"rounded-lg bg-slate-100 border border-slate-200 flex items-center justify-center",
SIZE_CLASSES[aspectRatio],
].join(" ")}>
<span className="text-xs text-slate-400 font-medium">{aspectRatio}</span>
</div>
);
}

View File

@ -0,0 +1,64 @@
import type { ComponentType } from "react";
import type { ChannelFormatOption, StudioChannel } from "@/features/studio/types/studio";
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from "@/components/icons/FilledIcons";
const iconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
tiktok: TiktokFilled,
};
interface ChannelCardProps {
option: ChannelFormatOption;
isSelected: boolean;
onSelect: (channel: StudioChannel | null) => void;
}
export function ChannelCard({ option, isSelected, onSelect }: ChannelCardProps) {
const Icon = iconMap[option.icon] ?? GlobeFilled;
return (
<button
type="button"
onClick={() => onSelect(isSelected ? null : option.channel)}
className={[
"relative flex flex-col items-center gap-3 p-6 rounded-2xl border-2 transition-all",
isSelected
? "border-violet-500 bg-status-good-bg/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]"
: "border-neutral-20 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-status-good-border",
].join(" ")}
>
{/* 선택 체크 뱃지 */}
{isSelected && (
<div className="absolute top-3 right-3 w-5 h-5 rounded-full bg-violet-500 flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
{/* 채널 아이콘 */}
<div className={[
"w-12 h-12 rounded-xl flex items-center justify-center",
isSelected ? "bg-status-good-bg" : "bg-neutral-10",
].join(" ")}>
<Icon size={24} className={isSelected ? "text-violet-500" : "text-status-good-dot"} />
</div>
<span className={[
"text-sm font-semibold",
isSelected ? "text-navy-900" : "text-neutral-70",
].join(" ")}>
{option.label}
</span>
</button>
);
}

View File

@ -0,0 +1,28 @@
import type { StudioChannel } from "@/features/studio/types/studio";
import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels";
import { ChannelCard } from "@/features/studio/ui/channelFormat/ChannelCard";
interface ChannelSelectGridProps {
selectedChannel: StudioChannel | null;
onChannelSelect: (channel: StudioChannel | null) => void;
}
export function ChannelSelectGrid({ selectedChannel, onChannelSelect }: ChannelSelectGridProps) {
return (
<div className="mb-10">
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-2"> </h3>
<p className="text-sm text-neutral-60 mb-6"> </p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{MOCK_CHANNEL_OPTIONS.map((option) => (
<ChannelCard
key={option.channel}
option={option}
isSelected={selectedChannel === option.channel}
onSelect={onChannelSelect}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import type { ContentFormat } from "@/features/studio/types/studio";
import { AspectRatioPreview } from "@/features/studio/ui/channelFormat/AspectRatioPreview";
interface FormatCardProps {
fmt: {
key: ContentFormat;
label: string;
aspectRatio: "16:9" | "9:16" | "1:1" | "4:5";
};
isSelected: boolean;
onSelect: (format: ContentFormat | null) => void;
}
export function FormatCard({ fmt, isSelected, onSelect }: FormatCardProps) {
return (
<button
type="button"
onClick={() => onSelect(isSelected ? null : fmt.key)}
className={[
"relative flex flex-col items-center gap-3 p-5 rounded-2xl border-2 transition-all",
isSelected
? "border-violet-500 bg-status-good-bg/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]"
: "border-neutral-20 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-status-good-border",
].join(" ")}
>
{/* 선택 체크 뱃지 */}
{isSelected && (
<div className="absolute top-3 right-3 w-5 h-5 rounded-full bg-violet-500 flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
<AspectRatioPreview aspectRatio={fmt.aspectRatio} />
<span className={[
"text-sm font-semibold",
isSelected ? "text-navy-900" : "text-neutral-70",
].join(" ")}>
{fmt.label}
</span>
</button>
);
}

View File

@ -0,0 +1,30 @@
import type { ChannelFormatOption, ContentFormat } from "@/features/studio/types/studio";
import { FormatCard } from "@/features/studio/ui/channelFormat/FormatCard";
interface FormatSelectGridProps {
option: ChannelFormatOption;
selectedFormat: ContentFormat | null;
onFormatSelect: (format: ContentFormat | null) => void;
}
export function FormatSelectGrid({ option, selectedFormat, onFormatSelect }: FormatSelectGridProps) {
return (
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-2"> </h3>
<p className="text-sm text-neutral-60 mb-6">
{option.label}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{option.formats.map((fmt) => (
<FormatCard
key={fmt.key}
fmt={fmt}
isSelected={selectedFormat === fmt.key}
onSelect={onFormatSelect}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import type { GenerateOutputType } from "@/features/studio/types/studio";
import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons";
interface OutputTypeTabsProps {
outputType: GenerateOutputType;
onOutputTypeChange: (type: GenerateOutputType) => void;
}
const TABS = [
{ key: "image" as const, label: "이미지 생성", Icon: FileTextFilled },
{ key: "video" as const, label: "영상 생성", Icon: VideoFilled },
];
export function OutputTypeTabs({ outputType, onOutputTypeChange }: OutputTypeTabsProps) {
return (
<div className="flex gap-2 mb-8">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onOutputTypeChange(tab.key)}
className={[
"flex items-center gap-2 px-5 py-3 rounded-full text-sm font-medium transition-all",
outputType === tab.key
? "bg-gradient-to-r from-violet-700 to-navy-950 text-white shadow-md"
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10",
].join(" ")}
>
<tab.Icon
size={16}
className={outputType === tab.key ? "text-white" : "text-status-good-dot"}
/>
{tab.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,112 @@
import { motion } from "motion/react";
import type { GenerateOutputType } from "@/features/studio/types/studio";
import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons";
import type { GenerateResult } from "@/features/studio/services/generateImage";
type GenerateStatus = "idle" | "generating" | "done" | "error";
interface PreviewAreaProps {
status: GenerateStatus;
result: GenerateResult | null;
errorMsg: string;
outputType: GenerateOutputType;
channelLabel: string;
formatLabel: string;
aspectRatio: string;
aspectClass: string;
}
export function PreviewArea({
status,
result,
errorMsg,
outputType,
channelLabel,
formatLabel,
aspectRatio,
aspectClass,
}: PreviewAreaProps) {
return (
<div className="flex flex-col items-center">
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 self-start"></h3>
<div
className={`${aspectClass} rounded-2xl overflow-hidden border border-neutral-30 bg-neutral-10 flex items-center justify-center relative`}
>
{/* Idle */}
{status === "idle" && (
<div className="text-center px-6">
<div className="w-16 h-16 rounded-2xl bg-status-good-bg flex items-center justify-center mx-auto mb-4">
{outputType === "image" ? (
<FileTextFilled size={28} className="text-status-good-dot" />
) : (
<VideoFilled size={28} className="text-status-good-dot" />
)}
</div>
<p className="text-sm text-neutral-60"> </p>
</div>
)}
{/* Generating */}
{status === "generating" && (
<div className="text-center">
<div className="w-12 h-12 border-4 border-violet-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-sm text-neutral-60 mb-1">
{outputType === "image" ? "이미지" : "영상"} ...
</p>
<p className="text-xs text-neutral-60">AI </p>
</div>
)}
{/* Error */}
{status === "error" && (
<div className="text-center px-6">
<div className="w-14 h-14 rounded-full bg-status-critical-bg flex items-center justify-center mx-auto mb-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="currentColor" className="text-status-critical-dot" opacity="0.3" />
<path d="M12 8v4M12 16h.01" stroke="currentColor" className="text-status-critical-text" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<p className="text-sm text-status-critical-text">{errorMsg}</p>
</div>
)}
{/* Done — with image */}
{status === "done" && result?.imageDataUrl && (
<motion.img
src={result.imageDataUrl}
alt="Generated content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{/* Done — video placeholder */}
{status === "done" && !result?.imageDataUrl && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-gradient-to-br from-status-good-bg via-white to-marketing-cream flex flex-col items-center justify-center p-6"
>
<div className="w-14 h-14 rounded-full bg-violet-500 flex items-center justify-center mb-4">
<svg width="24" height="24" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-lg font-semibold text-navy-900 mb-1"> </p>
<p className="text-sm text-neutral-60 text-center">
{channelLabel} {formatLabel}
{outputType === "image" ? " 이미지" : " 영상"}
</p>
<div className="mt-4 px-4 py-2 rounded-full bg-status-good-bg text-status-good-text text-xs font-medium border border-status-good-border">
{aspectRatio} | {outputType === "image" ? "PNG" : "MP4"}
</div>
</motion.div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import type { GenerateOutputType } from "@/features/studio/types/studio";
import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons";
import type { GenerateResult } from "@/features/studio/services/generateImage";
type GenerateStatus = "idle" | "generating" | "done" | "error";
interface SettingsSummaryProps {
channelLabel: string;
formatLabel: string;
aspectRatio: string;
trackName: string;
narrationValue: string;
subtitleValue: string;
outputType: GenerateOutputType;
status: GenerateStatus;
result: GenerateResult | null;
errorMsg: string;
onGenerate: () => void;
onReset: () => void;
onDownload: () => void;
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-2 border-b border-neutral-20 last:border-0">
<span className="text-sm text-neutral-60">{label}</span>
<span className="text-sm font-medium text-navy-900">{value}</span>
</div>
);
}
export function SettingsSummary({
channelLabel,
formatLabel,
aspectRatio,
trackName,
narrationValue,
subtitleValue,
outputType,
status,
result,
errorMsg,
onGenerate,
onReset,
onDownload,
}: SettingsSummaryProps) {
return (
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4"> </h3>
<div className="bg-white rounded-2xl border border-neutral-20 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 space-y-4">
<SummaryRow label="채널" value={channelLabel} />
<SummaryRow label="포맷" value={formatLabel} />
<SummaryRow label="비율" value={aspectRatio} />
<SummaryRow label="음악" value={trackName} />
<SummaryRow label="나레이션" value={narrationValue} />
<SummaryRow label="자막" value={subtitleValue} />
<SummaryRow label="출력" value={outputType === "image" ? "이미지" : "영상"} />
</div>
{/* Generate / Retry button */}
{(status === "idle" || status === "error") && (
<button
type="button"
onClick={onGenerate}
className="mt-6 w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-violet-700 to-navy-950 text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
{outputType === "image" ? (
<FileTextFilled size={18} className="text-white" />
) : (
<VideoFilled size={18} className="text-white" />
)}
{status === "error" ? "다시 시도" : outputType === "image" ? "이미지 생성" : "영상 생성"}
</button>
)}
{/* Error message */}
{status === "error" && (
<p className="mt-3 text-sm text-status-critical-text text-center">{errorMsg}</p>
)}
{/* Done actions */}
{status === "done" && (
<div className="mt-6 flex gap-3">
{result?.imageDataUrl && (
<button
type="button"
onClick={onDownload}
className="flex-1 py-3 rounded-full bg-gradient-to-r from-violet-700 to-navy-950 text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
</button>
)}
<button
type="button"
onClick={onReset}
className="flex-1 py-3 rounded-full bg-white border border-neutral-30 text-neutral-70 text-sm font-medium shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-neutral-10 transition-all"
>
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
// ─── features/studio/ui barrel export ──────────────────────────────────────
export { StudioWizardProvider } from "@/features/studio/ui/StudioWizardProvider";
export { StudioProgressBar } from "@/features/studio/ui/StudioProgressBar";
// ── 위저드 공용 섹션
export { StudioWizardHeaderSection } from "@/features/studio/ui/StudioWizardHeaderSection";
export { StudioWizardFooterSection } from "@/features/studio/ui/StudioWizardFooterSection";
// ── 스텝 섹션
export { StudioChannelFormatSection } from "@/features/studio/ui/StudioChannelFormatSection";
export { StudioStrategySourceSection } from "@/features/studio/ui/StudioStrategySourceSection";
export { StudioSoundSection } from "@/features/studio/ui/StudioSoundSection";
export { StudioGenerateSection } from "@/features/studio/ui/StudioGenerateSection";
export { StudioBlogEditorSection } from "@/features/studio/ui/StudioBlogEditorSection";

View File

@ -0,0 +1,29 @@
import type { MusicGenre } from "@/features/studio/types/studio";
import { GENRE_OPTIONS } from "@/features/studio/content/sound";
interface GenreChipListProps {
selectedGenre: MusicGenre;
onGenreChange: (genre: MusicGenre) => void;
}
export function GenreChipList({ selectedGenre, onGenreChange }: GenreChipListProps) {
return (
<div className="flex flex-wrap gap-2">
{GENRE_OPTIONS.map((g) => (
<button
key={g.key}
type="button"
onClick={() => onGenreChange(g.key)}
className={[
"px-5 py-3 rounded-full text-sm font-medium transition-all",
selectedGenre === g.key
? "bg-gradient-to-r from-violet-700 to-navy-950 text-white shadow-md"
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10 shadow-[2px_3px_6px_rgba(0,0,0,0.04)]",
].join(" ")}
>
{g.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,78 @@
import type { NarrationLanguage, NarrationVoice } from "@/features/studio/types/studio";
import { LANGUAGE_OPTIONS } from "@/features/studio/content/sound";
import { MessageFilled } from "@/components/icons/FilledIcons";
interface NarrationSettingsProps {
language: NarrationLanguage;
voice: NarrationVoice;
onLanguageChange: (lang: NarrationLanguage) => void;
onVoiceChange: (voice: NarrationVoice) => void;
}
const VOICE_OPTIONS: { key: NarrationVoice; label: string }[] = [
{ key: "female", label: "Female" },
{ key: "male", label: "Male" },
];
export function NarrationSettings({
language,
voice,
onLanguageChange,
onVoiceChange,
}: NarrationSettingsProps) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* 언어 선택 */}
<div>
<p className="text-sm font-medium text-neutral-70 mb-3"></p>
<div className="flex flex-wrap gap-2">
{LANGUAGE_OPTIONS.map((lang) => (
<button
key={lang.key}
type="button"
onClick={() => onLanguageChange(lang.key)}
className={[
"px-4 py-2 rounded-full text-sm font-medium transition-all",
language === lang.key
? "bg-gradient-to-r from-violet-700 to-navy-950 text-white shadow-md"
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10",
].join(" ")}
>
{lang.label}
</button>
))}
</div>
</div>
{/* 보이스 선택 */}
<div>
<p className="text-sm font-medium text-neutral-70 mb-3"></p>
<div className="flex gap-3">
{VOICE_OPTIONS.map((v) => (
<button
key={v.key}
type="button"
onClick={() => onVoiceChange(v.key)}
className={[
"flex items-center gap-2 px-5 py-3 rounded-2xl border-2 transition-all",
voice === v.key
? "border-violet-500 bg-status-good-bg/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]"
: "border-neutral-20 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-status-good-border",
].join(" ")}
>
<MessageFilled size={18} className={voice === v.key ? "text-violet-500" : "text-status-good-dot"} />
<span className={[
"text-sm font-medium",
voice === v.key ? "text-navy-900" : "text-neutral-70",
].join(" ")}>
{v.label}
</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
interface ToggleSwitchProps {
enabled: boolean;
onToggle: (next: boolean) => void;
label: string;
}
export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={label}
onClick={() => onToggle(!enabled)}
className={[
"relative w-12 h-6 rounded-full transition-colors shrink-0",
enabled ? "bg-violet-500" : "bg-neutral-30",
].join(" ")}
>
<div className={[
"absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-6" : "translate-x-1",
].join(" ")} />
</button>
);
}

View File

@ -0,0 +1,40 @@
import type { MusicTrack } from "@/features/studio/types/studio";
import { MusicFilled } from "@/components/icons/FilledIcons";
interface TrackCardProps {
track: MusicTrack;
isSelected: boolean;
onSelect: (trackId: string | null) => void;
}
export function TrackCard({ track, isSelected, onSelect }: TrackCardProps) {
return (
<button
type="button"
onClick={() => onSelect(isSelected ? null : track.id)}
className={[
"flex items-center gap-3 p-4 rounded-2xl border-2 text-left transition-all",
isSelected
? "border-violet-500 bg-status-good-bg/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]"
: "border-neutral-20 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-status-good-border",
].join(" ")}
>
<div className={[
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0",
isSelected ? "bg-status-good-bg" : "bg-neutral-10",
].join(" ")}>
<MusicFilled size={18} className={isSelected ? "text-violet-500" : "text-status-good-dot"} />
</div>
<div className="min-w-0">
<p className={[
"text-sm font-semibold truncate break-keep",
isSelected ? "text-navy-900" : "text-neutral-70",
].join(" ")}>
{track.name}
</p>
<p className="text-xs text-neutral-60 mt-0.5">{track.duration}</p>
</div>
</button>
);
}

View File

@ -0,0 +1,28 @@
import type { MusicGenre } from "@/features/studio/types/studio";
import { MOCK_MUSIC_TRACKS } from "@/features/studio/mocks/musicTracks";
import { TrackCard } from "@/features/studio/ui/sound/TrackCard";
interface TrackSelectGridProps {
genre: MusicGenre;
selectedTrackId: string | null;
onTrackChange: (trackId: string | null) => void;
}
export function TrackSelectGrid({ genre, selectedTrackId, onTrackChange }: TrackSelectGridProps) {
if (genre === "none") return null;
const tracks = MOCK_MUSIC_TRACKS.filter((t) => t.genre === genre);
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{tracks.map((track) => (
<TrackCard
key={track.id}
track={track}
isSelected={selectedTrackId === track.id}
onSelect={onTrackChange}
/>
))}
</div>
);
}

View File

@ -0,0 +1,43 @@
import type { ContentPillar, ContentPillarId } from "@/features/studio/types/studio";
interface PillarCardProps {
pillar: ContentPillar;
isSelected: boolean;
onSelect: (id: ContentPillarId) => void;
}
export function PillarCard({ pillar, isSelected, onSelect }: PillarCardProps) {
return (
<button
type="button"
onClick={() => onSelect(pillar.id)}
className={[
"relative w-full text-left p-5 rounded-2xl border-2 transition-all",
isSelected
? "border-violet-500 bg-status-good-bg/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]"
: "border-neutral-20 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-status-good-border",
].join(" ")}
>
{/* 선택 체크 뱃지 */}
{isSelected && (
<div className="absolute top-4 right-4 w-5 h-5 rounded-full bg-violet-500 flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
<div className="flex items-start gap-3">
{/* 필라 컬러 바 */}
<div
className="w-1 h-10 rounded-full shrink-0 mt-1"
style={{ backgroundColor: pillar.color }}
/>
<div>
<h4 className="font-semibold text-navy-900 mb-1 break-keep">{pillar.title}</h4>
<p className="text-sm text-neutral-60 break-keep">{pillar.description}</p>
</div>
</div>
</button>
);
}

View File

@ -0,0 +1,28 @@
import type { ContentPillarId } from "@/features/studio/types/studio";
import { MOCK_CONTENT_PILLARS } from "@/features/studio/mocks/contentPillars";
import { PillarCard } from "@/features/studio/ui/strategySource/PillarCard";
interface PillarSelectListProps {
selectedPillarId: ContentPillarId | null;
onPillarSelect: (id: ContentPillarId) => void;
}
export function PillarSelectList({ selectedPillarId, onPillarSelect }: PillarSelectListProps) {
return (
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-2 break-keep"> </h3>
<p className="text-sm text-neutral-60 mb-6 break-keep"> </p>
<div className="space-y-3">
{MOCK_CONTENT_PILLARS.map((pillar) => (
<PillarCard
key={pillar.id}
pillar={pillar}
isSelected={selectedPillarId === pillar.id}
onSelect={onPillarSelect}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
import type { AssetSourceType } from "@/features/studio/types/studio";
interface SourceCardProps {
source: {
key: AssetSourceType;
title: string;
description: string;
};
isSelected: boolean;
onToggle: (source: AssetSourceType) => void;
}
export function SourceCard({ source, isSelected, onToggle }: SourceCardProps) {
return (
<button
type="button"
onClick={() => onToggle(source.key)}
className={[
"relative w-full text-left p-5 rounded-2xl border-2 transition-all",
isSelected
? "border-violet-500 bg-status-good-bg/30 shadow-[3px_4px_12px_rgba(108,92,231,0.12)]"
: "border-neutral-20 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-status-good-border",
].join(" ")}
>
<div className="flex items-center gap-3">
{/* 체크박스 인디케이터 */}
<div className={[
"w-5 h-5 rounded border-2 shrink-0 flex items-center justify-center transition-all",
isSelected
? "border-violet-500 bg-violet-500"
: "border-neutral-40 bg-white",
].join(" ")}>
{isSelected && (
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<div>
<h4 className="font-semibold text-navy-900 mb-1 break-keep">{source.title}</h4>
<p className="text-sm text-neutral-60 break-keep">{source.description}</p>
</div>
</div>
</button>
);
}

View File

@ -0,0 +1,33 @@
import type { AssetSourceType } from "@/features/studio/types/studio";
import { SourceCard } from "@/features/studio/ui/strategySource/SourceCard";
const ASSET_SOURCE_OPTIONS: { key: AssetSourceType; title: string; description: string }[] = [
{ key: "collected", title: "수집된 에셋", description: "홈페이지, 블로그, SNS에서 수집한 기존 에셋" },
{ key: "my_assets", title: "My Assets", description: "직접 업로드한 이미지, 영상, 텍스트 파일" },
{ key: "ai_generated", title: "AI 생성", description: "AI가 새로 생성하는 이미지, 텍스트, 영상" },
];
interface SourceSelectListProps {
selectedSources: AssetSourceType[];
onSourceToggle: (source: AssetSourceType) => void;
}
export function SourceSelectList({ selectedSources, onSourceToggle }: SourceSelectListProps) {
return (
<div>
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-2 break-keep"> </h3>
<p className="text-sm text-neutral-60 mb-6 break-keep"> ( )</p>
<div className="space-y-3">
{ASSET_SOURCE_OPTIONS.map((source) => (
<SourceCard
key={source.key}
source={source}
isSelected={selectedSources.includes(source.key)}
onToggle={onSourceToggle}
/>
))}
</div>
</div>
);
}

View File

@ -30,7 +30,7 @@ const PAGE_FLOW: FlowStep[] = [
navigatePath: DEFAULT_PLAN_NAV_PATH,
isActive: (p) => p === "/plan" || p.startsWith("/plan/"),
},
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio/demo", isActive: (p) => p === "/studio" || p.startsWith("/studio/") },
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
{
id: "distribute",

18
src/pages/Studio.tsx Normal file
View File

@ -0,0 +1,18 @@
import { StudioWizardProvider } from "@/features/studio/ui/StudioWizardProvider";
import { StudioWizardHeaderSection } from "@/features/studio/ui/StudioWizardHeaderSection";
import { StudioWizardFooterSection } from "@/features/studio/ui/StudioWizardFooterSection";
import { StudioStepAnimator } from "@/features/studio/ui/StudioStepAnimator";
export function StudioPage() {
return (
<StudioWizardProvider>
<div className="min-h-screen pt-24 pb-32 px-6" data-studio-content>
<div className="max-w-5xl mx-auto">
<StudioWizardHeaderSection />
<StudioStepAnimator />
<StudioWizardFooterSection />
</div>
</div>
</StudioWizardProvider>
);
}