Compare commits
No commits in common. "feature/studio" and "master" have entirely different histories.
feature/st
...
master
|
|
@ -8,11 +8,8 @@
|
||||||
"name": "fe",
|
"name": "fe",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.48.0",
|
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"lucide-react": "^1.7.0",
|
|
||||||
"motion": "^12.38.0",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
|
@ -918,29 +915,6 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
|
@ -1043,70 +1017,6 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
|
|
@ -2334,6 +2244,7 @@
|
||||||
"version": "24.12.0",
|
"version": "24.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
|
|
@ -2359,12 +2270,6 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.57.0",
|
"version": "8.57.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
|
||||||
|
|
@ -2704,15 +2609,6 @@
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
|
|
@ -2777,26 +2673,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.8",
|
"version": "2.10.8",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
||||||
|
|
@ -2810,15 +2686,6 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
|
|
@ -2864,12 +2731,6 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
|
@ -3052,19 +2913,11 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|
@ -3129,15 +2982,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.313",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
||||||
|
|
@ -3483,12 +3327,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -3528,29 +3366,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|
@ -3638,45 +3453,6 @@
|
||||||
"node": ">= 6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -3701,34 +3477,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|
@ -3809,32 +3557,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
|
@ -3920,19 +3642,6 @@
|
||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -4050,15 +3759,6 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
|
|
@ -4100,27 +3800,6 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
@ -4175,12 +3854,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lower-case": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
|
@ -4201,15 +3874,6 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
@ -4263,51 +3927,11 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
|
|
@ -4347,44 +3971,6 @@
|
||||||
"tslib": "^2.0.3"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
|
|
@ -4442,19 +4028,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -4576,30 +4149,6 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
|
@ -4695,15 +4244,6 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
|
|
@ -4749,26 +4289,6 @@
|
||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
|
@ -4944,6 +4464,7 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
|
|
@ -5001,6 +4522,7 @@
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
|
|
@ -5149,15 +4671,6 @@
|
||||||
"vite": "*"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
@ -5184,27 +4697,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,8 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.48.0",
|
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"lucide-react": "^1.7.0",
|
|
||||||
"motion": "^12.38.0",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout";
|
||||||
import { Home } from "@/pages/Home";
|
import { Home } from "@/pages/Home";
|
||||||
import { PlanPage } from "@/pages/Plan";
|
import { PlanPage } from "@/pages/Plan";
|
||||||
import { ReportPage } from "@/pages/Report";
|
import { ReportPage } from "@/pages/Report";
|
||||||
import { StudioPage } from "@/pages/Studio";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
||||||
|
|
@ -19,7 +18,6 @@ function App() {
|
||||||
<Route element={<MainSubNavLayout />}>
|
<Route element={<MainSubNavLayout />}>
|
||||||
<Route path="report/:id" element={<ReportPage />} />
|
<Route path="report/:id" element={<ReportPage />} />
|
||||||
<Route path="plan/:id" element={<PlanPage />} />
|
<Route path="plan/:id" element={<PlanPage />} />
|
||||||
<Route path="studio/:id" element={<StudioPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// ─── 위저드 스텝 플로우 정의 ────────────────────────────────────────────────
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
/** 채널 & 포맷 선택 섹션 카피 */
|
|
||||||
|
|
||||||
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": "세로형",
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/** 사운드 설정 섹션 카피 */
|
|
||||||
|
|
||||||
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: "中文" },
|
|
||||||
];
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/** 콘텐츠 전략 & 소스 선택 섹션 카피 */
|
|
||||||
|
|
||||||
export const PILLAR_COLUMN_TITLE = "콘텐츠 전략";
|
|
||||||
export const PILLAR_COLUMN_DESCRIPTION = "콘텐츠의 핵심 메시지 필러를 선택하세요";
|
|
||||||
|
|
||||||
export const SOURCE_COLUMN_TITLE = "소스 선택";
|
|
||||||
export const SOURCE_COLUMN_DESCRIPTION = "콘텐츠에 사용할 에셋 소스를 선택하세요 (복수 선택)";
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
// ─── 스튜디오 위저드 상태 관리 훅 ───────────────────────────────────────────
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// ─── 채널 & 포맷 옵션 목 데이터 ─────────────────────────────────────────────
|
|
||||||
// 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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// ─── 콘텐츠 필라 목 데이터 ──────────────────────────────────────────────────
|
|
||||||
// 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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// ─── 음악 트랙 목 데이터 ────────────────────────────────────────────────────
|
|
||||||
// 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' },
|
|
||||||
];
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// ─── 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { StudioProgressBar } from "@/features/studio/ui/StudioProgressBar";
|
|
||||||
|
|
||||||
export function StudioWizardHeaderSection() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center mb-12">
|
|
||||||
<StudioProgressBar />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
// ─── 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";
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -30,7 +30,7 @@ const PAGE_FLOW: FlowStep[] = [
|
||||||
navigatePath: DEFAULT_PLAN_NAV_PATH,
|
navigatePath: DEFAULT_PLAN_NAV_PATH,
|
||||||
isActive: (p) => p === "/plan" || p.startsWith("/plan/"),
|
isActive: (p) => p === "/plan" || p.startsWith("/plan/"),
|
||||||
},
|
},
|
||||||
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio/demo", isActive: (p) => p === "/studio" || p.startsWith("/studio/") },
|
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
||||||
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
||||||
{
|
{
|
||||||
id: "distribute",
|
id: "distribute",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue