diff --git a/package-lock.json b/package-lock.json index 79fd4d7..16c2877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "fe", "version": "0.0.0", "dependencies": { + "@google/genai": "^1.48.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", + "lucide-react": "^1.7.0", + "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1", @@ -915,6 +918,29 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google/genai": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz", + "integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1017,6 +1043,70 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2244,7 +2334,6 @@ "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2270,6 +2359,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -2609,6 +2704,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2673,6 +2777,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", @@ -2686,6 +2810,15 @@ "node": ">=6.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2731,6 +2864,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2913,11 +3052,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2982,6 +3129,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.313", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", @@ -3327,6 +3483,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3366,6 +3528,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3453,6 +3638,45 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3477,6 +3701,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3557,6 +3809,32 @@ "dev": true, "license": "MIT" }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3642,6 +3920,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3759,6 +4050,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3800,6 +4100,27 @@ "node": ">=6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3854,6 +4175,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -3874,6 +4201,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3927,11 +4263,51 @@ "node": "*" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3971,6 +4347,44 @@ "tslib": "^2.0.3" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -4028,6 +4442,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4149,6 +4576,30 @@ "node": ">= 0.8.0" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4244,6 +4695,15 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -4289,6 +4749,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4464,7 +4944,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -4522,7 +5001,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4671,6 +5149,15 @@ "vite": "*" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4697,6 +5184,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fdd494f..c84f44c 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@google/genai": "^1.48.0", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", + "lucide-react": "^1.7.0", + "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1", diff --git a/src/app/App.tsx b/src/app/App.tsx index 4dff973..9195c19 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout"; import { Home } from "@/pages/Home"; import { PlanPage } from "@/pages/Plan"; import { ReportPage } from "@/pages/Report"; +import { StudioPage } from "@/pages/Studio"; function App() { @@ -18,6 +19,7 @@ function App() { }> } /> } /> + } /> ) diff --git a/src/components/icons/FilledIcons.tsx b/src/components/icons/FilledIcons.tsx new file mode 100644 index 0000000..fc9bf5f --- /dev/null +++ b/src/components/icons/FilledIcons.tsx @@ -0,0 +1,164 @@ +/** + * Filled/Shape-style icons for Channel Strategy & Content Calendar. + * Soft pastel colors, no outlines — all shapes use fill only. + */ + +interface IconProps { + size?: number; + className?: string; +} + +export function YoutubeFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + ); +} + +export function InstagramFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + ); +} + +export function FacebookFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + ); +} + +export function GlobeFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + + ); +} + +export function VideoFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + ); +} + +export function MessageFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + ); +} + +export function CalendarFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + + + + + ); +} + +export function FileTextFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + + ); +} + +export function ShareFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + + + ); +} + +export function MegaphoneFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + ); +} + +export function TiktokFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + ); +} + +export function MusicFilled({ size = 20, className = '' }: IconProps) { + return ( + + + + + + + + + ); +} + +/** + * 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 ( + + + + + + + + + + + ); +} diff --git a/src/features/studio/config/studioSteps.ts b/src/features/studio/config/studioSteps.ts new file mode 100644 index 0000000..5d126a5 --- /dev/null +++ b/src/features/studio/config/studioSteps.ts @@ -0,0 +1,33 @@ +// ─── 위저드 스텝 플로우 정의 ──────────────────────────────────────────────── +// naver_blog 채널은 BLOG_FLOW, 나머지 채널은 VIDEO_FLOW를 따름 + +import type { StudioChannel } from "@/features/studio/types/studio"; + +export type VideoStepKey = 'channel-format' | 'strategy-source' | 'sound' | 'generate'; +export type BlogStepKey = 'channel-format' | 'strategy-source' | 'blog-editor'; +export type StepKey = VideoStepKey | BlogStepKey; + +export interface StepConfig { + key: StepKey; + label: string; +} + +export const VIDEO_FLOW_STEPS: StepConfig[] = [ + { key: 'channel-format', label: '채널 선택' }, + { key: 'strategy-source', label: '전략 선택' }, + { key: 'sound', label: '사운드' }, + { key: 'generate', label: '생성' }, +]; + +export const BLOG_FLOW_STEPS: StepConfig[] = [ + { key: 'channel-format', label: '채널 선택' }, + { key: 'strategy-source', label: '전략 선택' }, + { key: 'blog-editor', label: '콘텐츠 생성' }, +]; + +export const BLOG_CHANNELS: StudioChannel[] = ['naver_blog']; + +export function getStepsForChannel(channel: StudioChannel | null): StepConfig[] { + if (channel && BLOG_CHANNELS.includes(channel)) return BLOG_FLOW_STEPS; + return VIDEO_FLOW_STEPS; +} diff --git a/src/features/studio/content/channelFormat.ts b/src/features/studio/content/channelFormat.ts new file mode 100644 index 0000000..8f87914 --- /dev/null +++ b/src/features/studio/content/channelFormat.ts @@ -0,0 +1,15 @@ +/** 채널 & 포맷 선택 섹션 카피 */ + +export const CHANNEL_FORMAT_SECTION_TITLE = "채널 & 포맷 선택"; +export const CHANNEL_FORMAT_SECTION_DESCRIPTION = + "콘텐츠를 게시할 채널과 포맷을 선택하세요"; + +export const CHANNEL_SELECT_LABEL = "채널"; +export const FORMAT_SELECT_LABEL = "포맷"; + +export const ASPECT_RATIO_LABEL: Record = { + "16:9": "가로형", + "9:16": "세로형", + "1:1": "정방형", + "4:5": "세로형", +}; diff --git a/src/features/studio/content/sound.ts b/src/features/studio/content/sound.ts new file mode 100644 index 0000000..5424782 --- /dev/null +++ b/src/features/studio/content/sound.ts @@ -0,0 +1,33 @@ +/** 사운드 설정 섹션 카피 */ + +import type { MusicGenre, NarrationLanguage } from "@/features/studio/types/studio"; + +// ── 섹션 타이틀 & 설명 ─────────────────────────────────────────────────────── +export const SOUND_MUSIC_TITLE = "배경 음악"; +export const SOUND_MUSIC_DESCRIPTION = "콘텐츠에 어울리는 음악 장르를 선택하세요"; + +export const SOUND_NARRATION_TITLE = "나레이션"; +export const SOUND_SUBTITLE_TITLE = "자막"; + +export const SOUND_SUBTITLE_ON_LABEL = "자막이 영상에 포함됩니다"; +export const SOUND_SUBTITLE_OFF_LABEL = "자막 없음"; + +export const SOUND_LANG_LABEL = "언어"; +export const SOUND_VOICE_LABEL = "보이스"; + +// ── 장르 옵션 ──────────────────────────────────────────────────────────────── +export const GENRE_OPTIONS: { key: MusicGenre; label: string }[] = [ + { key: "calm", label: "Calm" }, + { key: "upbeat", label: "Upbeat" }, + { key: "cinematic", label: "Cinematic" }, + { key: "corporate", label: "Corporate" }, + { key: "none", label: "No Music" }, +]; + +// ── 나레이션 언어 옵션 ──────────────────────────────────────────────────────── +export const LANGUAGE_OPTIONS: { key: NarrationLanguage; label: string }[] = [ + { key: "ko", label: "한국어" }, + { key: "en", label: "English" }, + { key: "ja", label: "日本語" }, + { key: "zh", label: "中文" }, +]; diff --git a/src/features/studio/content/strategySource.ts b/src/features/studio/content/strategySource.ts new file mode 100644 index 0000000..0b090f3 --- /dev/null +++ b/src/features/studio/content/strategySource.ts @@ -0,0 +1,7 @@ +/** 콘텐츠 전략 & 소스 선택 섹션 카피 */ + +export const PILLAR_COLUMN_TITLE = "콘텐츠 전략"; +export const PILLAR_COLUMN_DESCRIPTION = "콘텐츠의 핵심 메시지 필러를 선택하세요"; + +export const SOURCE_COLUMN_TITLE = "소스 선택"; +export const SOURCE_COLUMN_DESCRIPTION = "콘텐츠에 사용할 에셋 소스를 선택하세요 (복수 선택)"; diff --git a/src/features/studio/hooks/useStudioWizard.ts b/src/features/studio/hooks/useStudioWizard.ts new file mode 100644 index 0000000..7be966a --- /dev/null +++ b/src/features/studio/hooks/useStudioWizard.ts @@ -0,0 +1,120 @@ +// ─── 스튜디오 위저드 상태 관리 훅 ─────────────────────────────────────────── +// DEMO/src/components/studio/StudioWizard.tsx 의 상태 로직에서 이전 +// TODO: Phase 2 — Zustand store 로 전환 + +import { useState } from "react"; +import type { StudioState, StudioChannel, ContentFormat, ContentPillarId, AssetSourceType, SoundSettings, GenerateOutputType } from "@/features/studio/types/studio"; +import { getStepsForChannel } from "@/features/studio/config/studioSteps"; + +const DEFAULT_SOUND: SoundSettings = { + genre: 'calm', + trackId: null, + narrationEnabled: false, + narrationLanguage: 'ko', + narrationVoice: 'female', + subtitleEnabled: true, +}; + +const INITIAL_STATE: StudioState = { + channel: null, + format: null, + pillarId: null, + assetSources: [], + sound: DEFAULT_SOUND, + outputType: 'video', +}; + +export function useStudioWizard() { + const [state, setState] = useState(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) { + 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, + }; +} diff --git a/src/features/studio/hooks/useStudioWizardContext.ts b/src/features/studio/hooks/useStudioWizardContext.ts new file mode 100644 index 0000000..287f1b5 --- /dev/null +++ b/src/features/studio/hooks/useStudioWizardContext.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; +import type { useStudioWizard } from "@/features/studio/hooks/useStudioWizard"; + +export type StudioWizardContextValue = ReturnType; + +export const StudioWizardContext = createContext(null); + +export function useStudioWizardContext(): StudioWizardContextValue { + const ctx = useContext(StudioWizardContext); + if (!ctx) throw new Error("useStudioWizardContext must be used within StudioWizardProvider"); + return ctx; +} diff --git a/src/features/studio/mocks/channels.ts b/src/features/studio/mocks/channels.ts new file mode 100644 index 0000000..772d15f --- /dev/null +++ b/src/features/studio/mocks/channels.ts @@ -0,0 +1,53 @@ +// ─── 채널 & 포맷 옵션 목 데이터 ───────────────────────────────────────────── +// DEMO/src/types/studio.ts CHANNEL_OPTIONS 에서 이전 + +import type { ChannelFormatOption } from "@/features/studio/types/studio"; + +export const MOCK_CHANNEL_OPTIONS: ChannelFormatOption[] = [ + { + channel: 'youtube', + label: 'YouTube', + icon: 'youtube', + formats: [ + { key: 'shorts', label: 'Shorts', aspectRatio: '9:16' }, + { key: 'long_form', label: 'Long-form', aspectRatio: '16:9' }, + ], + }, + { + channel: 'instagram', + label: 'Instagram', + icon: 'instagram', + formats: [ + { key: 'reels', label: 'Reels', aspectRatio: '9:16' }, + { key: 'carousel', label: 'Carousel', aspectRatio: '1:1' }, + { key: 'feed_image', label: 'Feed Image', aspectRatio: '1:1' }, + { key: 'stories', label: 'Stories', aspectRatio: '9:16' }, + ], + }, + { + channel: 'naver_blog', + label: 'Naver Blog', + icon: 'globe', + formats: [ + { key: 'seo_post', label: 'SEO Post', aspectRatio: '16:9' }, + { key: 'faq_post', label: 'FAQ Post', aspectRatio: '16:9' }, + ], + }, + { + channel: 'tiktok', + label: 'TikTok', + icon: 'tiktok', + formats: [ + { key: 'short_video', label: 'Short Video', aspectRatio: '9:16' }, + ], + }, + { + channel: 'facebook', + label: 'Facebook', + icon: 'facebook', + formats: [ + { key: 'ad_creative', label: 'Ad Creative', aspectRatio: '1:1' }, + { key: 'retarget_content', label: 'Retarget Content', aspectRatio: '16:9' }, + ], + }, +]; diff --git a/src/features/studio/mocks/contentPillars.ts b/src/features/studio/mocks/contentPillars.ts new file mode 100644 index 0000000..24126c0 --- /dev/null +++ b/src/features/studio/mocks/contentPillars.ts @@ -0,0 +1,31 @@ +// ─── 콘텐츠 필라 목 데이터 ────────────────────────────────────────────────── +// DEMO/src/components/studio/StrategySourceStep.tsx PILLARS 에서 이전 + +import type { ContentPillar } from "@/features/studio/types/studio"; + +export const MOCK_CONTENT_PILLARS: ContentPillar[] = [ + { + id: 'safety', + title: '안전과 신뢰', + description: '수술 안전 시스템, 21년 무사고, CCTV, 마취과 전문의 상주', + color: '#6C5CE7', + }, + { + id: 'expertise', + title: '전문성과 기술력', + description: '분야별 전문의, 최신 장비, 논문 실적, 학회 활동', + color: '#7A84D4', + }, + { + id: 'results', + title: '자연스러운 결과', + description: '비포/애프터, 환자 후기, 한번에 성공하는 성형', + color: '#9B8AD4', + }, + { + id: 'care', + title: '환자 중심 케어', + description: '상담 프로세스, 사후 관리, 1:1 맞춤 플랜', + color: '#D4A872', + }, +]; diff --git a/src/features/studio/mocks/musicTracks.ts b/src/features/studio/mocks/musicTracks.ts new file mode 100644 index 0000000..d6a5cbe --- /dev/null +++ b/src/features/studio/mocks/musicTracks.ts @@ -0,0 +1,19 @@ +// ─── 음악 트랙 목 데이터 ──────────────────────────────────────────────────── +// DEMO/src/types/studio.ts MUSIC_TRACKS 에서 이전 + +import type { MusicTrack } from "@/features/studio/types/studio"; + +export const MOCK_MUSIC_TRACKS: MusicTrack[] = [ + { id: 'calm-1', name: 'Gentle Morning', genre: 'calm', duration: '2:30' }, + { id: 'calm-2', name: 'Soft Healing', genre: 'calm', duration: '3:15' }, + { id: 'calm-3', name: 'Peaceful Flow', genre: 'calm', duration: '2:45' }, + { id: 'upbeat-1', name: 'Fresh Start', genre: 'upbeat', duration: '2:20' }, + { id: 'upbeat-2', name: 'Bright Day', genre: 'upbeat', duration: '2:50' }, + { id: 'upbeat-3', name: 'Energy Boost', genre: 'upbeat', duration: '3:00' }, + { id: 'cinematic-1', name: 'Grand Reveal', genre: 'cinematic', duration: '3:30' }, + { id: 'cinematic-2', name: 'Transformation', genre: 'cinematic', duration: '4:00' }, + { id: 'cinematic-3', name: 'Before & After', genre: 'cinematic', duration: '2:55' }, + { id: 'corp-1', name: 'Professional Trust', genre: 'corporate', duration: '2:40' }, + { id: 'corp-2', name: 'Clean & Modern', genre: 'corporate', duration: '3:10' }, + { id: 'corp-3', name: 'Confidence', genre: 'corporate', duration: '2:35' }, +]; diff --git a/src/features/studio/services/generateImage.ts b/src/features/studio/services/generateImage.ts new file mode 100644 index 0000000..f5571f7 --- /dev/null +++ b/src/features/studio/services/generateImage.ts @@ -0,0 +1,85 @@ +import { GoogleGenAI } from "@google/genai"; +import type { StudioState } from "@/features/studio/types/studio"; +import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels"; + +const client = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY ?? "" }); + +function buildPrompt(state: StudioState): string { + const channel = MOCK_CHANNEL_OPTIONS.find((c) => c.channel === state.channel); + const format = channel?.formats.find((f) => f.key === state.format); + + const pillarDescriptions: Record = { + 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 = { + 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 { + 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 }; +} diff --git a/src/features/studio/types/studio.ts b/src/features/studio/types/studio.ts new file mode 100644 index 0000000..57ab8c3 --- /dev/null +++ b/src/features/studio/types/studio.ts @@ -0,0 +1,66 @@ +// ─── Content Studio Types ─────────────────────────────────────────────────── +// DEMO/src/types/studio.ts 에서 이전 + +export type StudioChannel = 'youtube' | 'instagram' | 'naver_blog' | 'tiktok' | 'facebook'; + +export type ContentFormat = + | 'shorts' | 'long_form' // YouTube + | 'reels' | 'carousel' | 'feed_image' | 'stories' // Instagram + | 'seo_post' | 'faq_post' // Naver Blog + | 'short_video' // TikTok + | 'ad_creative' | 'retarget_content'; // Facebook + +export interface ChannelFormatOption { + channel: StudioChannel; + label: string; + icon: string; + formats: { + key: ContentFormat; + label: string; + aspectRatio: '16:9' | '9:16' | '1:1' | '4:5'; + }[]; +} + +export type ContentPillarId = string; + +export type AssetSourceType = 'collected' | 'my_assets' | 'ai_generated'; + +export type MusicGenre = 'calm' | 'upbeat' | 'cinematic' | 'corporate' | 'none'; + +export interface MusicTrack { + id: string; + name: string; + genre: MusicGenre; + duration: string; +} + +export type NarrationLanguage = 'ko' | 'en' | 'ja' | 'zh'; +export type NarrationVoice = 'male' | 'female'; + +export interface SoundSettings { + genre: MusicGenre; + trackId: string | null; + narrationEnabled: boolean; + narrationLanguage: NarrationLanguage; + narrationVoice: NarrationVoice; + subtitleEnabled: boolean; +} + +export type GenerateOutputType = 'image' | 'video'; + +export interface StudioState { + channel: StudioChannel | null; + format: ContentFormat | null; + pillarId: ContentPillarId | null; + assetSources: AssetSourceType[]; + sound: SoundSettings; + outputType: GenerateOutputType; +} + +// ─── 콘텐츠 필라 타입 ─────────────────────────────────────────────────────── +export interface ContentPillar { + id: ContentPillarId; + title: string; + description: string; + color: string; +} diff --git a/src/features/studio/ui/StudioBlogEditorSection.tsx b/src/features/studio/ui/StudioBlogEditorSection.tsx new file mode 100644 index 0000000..22b8ee7 --- /dev/null +++ b/src/features/studio/ui/StudioBlogEditorSection.tsx @@ -0,0 +1,12 @@ +// TODO: 5단계 리팩토링 — content/blogEditor.ts + blogEditor/ 하위 컴포넌트 + +export function StudioBlogEditorSection() { + return ( +
+
+ {/* TODO: 5단계 구현 예정 */} +

Blog Editor — 구현 예정

+
+
+ ); +} diff --git a/src/features/studio/ui/StudioChannelFormatSection.tsx b/src/features/studio/ui/StudioChannelFormatSection.tsx new file mode 100644 index 0000000..c9032a3 --- /dev/null +++ b/src/features/studio/ui/StudioChannelFormatSection.tsx @@ -0,0 +1,27 @@ +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; +import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels"; +import { ChannelSelectGrid } from "@/features/studio/ui/channelFormat/ChannelSelectGrid"; +import { FormatSelectGrid } from "@/features/studio/ui/channelFormat/FormatSelectGrid"; + +export function StudioChannelFormatSection() { + const { state, setChannel, setFormat } = useStudioWizardContext(); + + const selectedOption = MOCK_CHANNEL_OPTIONS.find((o) => o.channel === state.channel) ?? null; + + return ( +
+ + + {selectedOption && ( + + )} +
+ ); +} diff --git a/src/features/studio/ui/StudioGenerateSection.tsx b/src/features/studio/ui/StudioGenerateSection.tsx new file mode 100644 index 0000000..b537de7 --- /dev/null +++ b/src/features/studio/ui/StudioGenerateSection.tsx @@ -0,0 +1,117 @@ +import { useState, useCallback, useRef } from "react"; +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; +import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels"; +import { MOCK_MUSIC_TRACKS } from "@/features/studio/mocks/musicTracks"; +import { generateImage, type GenerateResult } from "@/features/studio/services/generateImage"; +import { OutputTypeTabs } from "@/features/studio/ui/generate/OutputTypeTabs"; +import { SettingsSummary } from "@/features/studio/ui/generate/SettingsSummary"; +import { PreviewArea } from "@/features/studio/ui/generate/PreviewArea"; + +type GenerateStatus = "idle" | "generating" | "done" | "error"; + +export function StudioGenerateSection() { + const { state, setOutputType } = useStudioWizardContext(); + + const [status, setStatus] = useState("idle"); + const [result, setResult] = useState(null); + const [errorMsg, setErrorMsg] = useState(""); + + const downloadRef = useRef(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 ( +
+ {/* Hidden download anchor */} + + + + +
+ + + +
+
+ ); +} diff --git a/src/features/studio/ui/StudioProgressBar.tsx b/src/features/studio/ui/StudioProgressBar.tsx new file mode 100644 index 0000000..6f77356 --- /dev/null +++ b/src/features/studio/ui/StudioProgressBar.tsx @@ -0,0 +1,56 @@ +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; +import type { StepConfig } from "@/features/studio/config/studioSteps"; + +export function StudioProgressBar() { + const { steps, currentStepIndex, goToStep } = useStudioWizardContext(); + + return ( +
+ {steps.map((s, i) => ( +
+ + + {/* 연결 선 */} + {i < steps.length - 1 && ( +
+ )} +
+ ))} +
+ ); +} diff --git a/src/features/studio/ui/StudioSoundSection.tsx b/src/features/studio/ui/StudioSoundSection.tsx new file mode 100644 index 0000000..ee24d3d --- /dev/null +++ b/src/features/studio/ui/StudioSoundSection.tsx @@ -0,0 +1,72 @@ +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; +import { GenreChipList } from "@/features/studio/ui/sound/GenreChipList"; +import { TrackSelectGrid } from "@/features/studio/ui/sound/TrackSelectGrid"; +import { ToggleSwitch } from "@/features/studio/ui/sound/ToggleSwitch"; +import { NarrationSettings } from "@/features/studio/ui/sound/NarrationSettings"; + +export function StudioSoundSection() { + const { state, setSound } = useStudioWizardContext(); + + const { sound } = state; + + return ( +
+ + {/* ── 배경 음악 ─────────────────────────────────────────────────── */} +
+

배경 음악

+

콘텐츠에 어울리는 음악 장르를 선택하세요

+ +
+ setSound({ genre, trackId: null })} + /> +
+ + setSound({ trackId })} + /> +
+ + {/* ── 나레이션 ─────────────────────────────────────────────────── */} +
+
+

나레이션

+ setSound({ narrationEnabled: enabled })} + label="나레이션 토글" + /> +
+ + {sound.narrationEnabled && ( + setSound({ narrationLanguage })} + onVoiceChange={(narrationVoice) => setSound({ narrationVoice })} + /> + )} +
+ + {/* ── 자막 ──────────────────────────────────────────────────────── */} +
+
+

자막

+ setSound({ subtitleEnabled: enabled })} + label="자막 토글" + /> + + {sound.subtitleEnabled ? "자막이 영상에 포함됩니다" : "자막 없음"} + +
+
+ +
+ ); +} diff --git a/src/features/studio/ui/StudioStepAnimator.tsx b/src/features/studio/ui/StudioStepAnimator.tsx new file mode 100644 index 0000000..cfe345d --- /dev/null +++ b/src/features/studio/ui/StudioStepAnimator.tsx @@ -0,0 +1,36 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; +import { StudioChannelFormatSection } from "@/features/studio/ui/StudioChannelFormatSection"; +import { StudioStrategySourceSection } from "@/features/studio/ui/StudioStrategySourceSection"; +import { StudioSoundSection } from "@/features/studio/ui/StudioSoundSection"; +import { StudioGenerateSection } from "@/features/studio/ui/StudioGenerateSection"; +import { StudioBlogEditorSection } from "@/features/studio/ui/StudioBlogEditorSection"; + +export function StudioStepAnimator() { + const { currentStep, currentStepIndex } = useStudioWizardContext(); + + function renderStep() { + switch (currentStep.key) { + case "channel-format": return ; + case "strategy-source": return ; + case "sound": return ; + case "generate": return ; + case "blog-editor": return ; + default: return null; + } + } + + return ( + + + {renderStep()} + + + ); +} diff --git a/src/features/studio/ui/StudioStrategySourceSection.tsx b/src/features/studio/ui/StudioStrategySourceSection.tsx new file mode 100644 index 0000000..e13a1e0 --- /dev/null +++ b/src/features/studio/ui/StudioStrategySourceSection.tsx @@ -0,0 +1,20 @@ +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; +import { PillarSelectList } from "@/features/studio/ui/strategySource/PillarSelectList"; +import { SourceSelectList } from "@/features/studio/ui/strategySource/SourceSelectList"; + +export function StudioStrategySourceSection() { + const { state, setPillar, toggleAssetSource } = useStudioWizardContext(); + + return ( +
+ + +
+ ); +} diff --git a/src/features/studio/ui/StudioWizardFooterSection.tsx b/src/features/studio/ui/StudioWizardFooterSection.tsx new file mode 100644 index 0000000..34b6b49 --- /dev/null +++ b/src/features/studio/ui/StudioWizardFooterSection.tsx @@ -0,0 +1,32 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useStudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; + +export function StudioWizardFooterSection() { + const { isFirstStep, isLastStep, canProceed, goNext, goPrev } = useStudioWizardContext(); + + return ( +
+ + + {!isLastStep ? ( + + ) : ( +
+ )} +
+ ); +} diff --git a/src/features/studio/ui/StudioWizardHeaderSection.tsx b/src/features/studio/ui/StudioWizardHeaderSection.tsx new file mode 100644 index 0000000..82e3c11 --- /dev/null +++ b/src/features/studio/ui/StudioWizardHeaderSection.tsx @@ -0,0 +1,9 @@ +import { StudioProgressBar } from "@/features/studio/ui/StudioProgressBar"; + +export function StudioWizardHeaderSection() { + return ( +
+ +
+ ); +} diff --git a/src/features/studio/ui/StudioWizardProvider.tsx b/src/features/studio/ui/StudioWizardProvider.tsx new file mode 100644 index 0000000..9c3ea8b --- /dev/null +++ b/src/features/studio/ui/StudioWizardProvider.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { useStudioWizard } from "@/features/studio/hooks/useStudioWizard"; +import { StudioWizardContext } from "@/features/studio/hooks/useStudioWizardContext"; + +interface StudioWizardProviderProps { + children: ReactNode; +} + +export function StudioWizardProvider({ children }: StudioWizardProviderProps) { + const wizard = useStudioWizard(); + + return ( + + {children} + + ); +} diff --git a/src/features/studio/ui/channelFormat/AspectRatioPreview.tsx b/src/features/studio/ui/channelFormat/AspectRatioPreview.tsx new file mode 100644 index 0000000..7c86982 --- /dev/null +++ b/src/features/studio/ui/channelFormat/AspectRatioPreview.tsx @@ -0,0 +1,21 @@ +interface AspectRatioPreviewProps { + aspectRatio: "16:9" | "9:16" | "1:1" | "4:5"; +} + +const SIZE_CLASSES: Record = { + "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 ( +
+ {aspectRatio} +
+ ); +} diff --git a/src/features/studio/ui/channelFormat/ChannelCard.tsx b/src/features/studio/ui/channelFormat/ChannelCard.tsx new file mode 100644 index 0000000..62a39ec --- /dev/null +++ b/src/features/studio/ui/channelFormat/ChannelCard.tsx @@ -0,0 +1,64 @@ +import type { ComponentType } from "react"; +import type { ChannelFormatOption, StudioChannel } from "@/features/studio/types/studio"; +import { + YoutubeFilled, + InstagramFilled, + FacebookFilled, + GlobeFilled, + TiktokFilled, +} from "@/components/icons/FilledIcons"; + +const iconMap: Record> = { + 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 ( + + ); +} diff --git a/src/features/studio/ui/channelFormat/ChannelSelectGrid.tsx b/src/features/studio/ui/channelFormat/ChannelSelectGrid.tsx new file mode 100644 index 0000000..d557dde --- /dev/null +++ b/src/features/studio/ui/channelFormat/ChannelSelectGrid.tsx @@ -0,0 +1,28 @@ +import type { StudioChannel } from "@/features/studio/types/studio"; +import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels"; +import { ChannelCard } from "@/features/studio/ui/channelFormat/ChannelCard"; + +interface ChannelSelectGridProps { + selectedChannel: StudioChannel | null; + onChannelSelect: (channel: StudioChannel | null) => void; +} + +export function ChannelSelectGrid({ selectedChannel, onChannelSelect }: ChannelSelectGridProps) { + return ( +
+

채널 선택

+

콘텐츠를 게시할 채널을 선택하세요

+ +
+ {MOCK_CHANNEL_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} diff --git a/src/features/studio/ui/channelFormat/FormatCard.tsx b/src/features/studio/ui/channelFormat/FormatCard.tsx new file mode 100644 index 0000000..36b89ff --- /dev/null +++ b/src/features/studio/ui/channelFormat/FormatCard.tsx @@ -0,0 +1,45 @@ +import type { ContentFormat } from "@/features/studio/types/studio"; +import { AspectRatioPreview } from "@/features/studio/ui/channelFormat/AspectRatioPreview"; + +interface FormatCardProps { + fmt: { + key: ContentFormat; + label: string; + aspectRatio: "16:9" | "9:16" | "1:1" | "4:5"; + }; + isSelected: boolean; + onSelect: (format: ContentFormat | null) => void; +} + +export function FormatCard({ fmt, isSelected, onSelect }: FormatCardProps) { + return ( + + ); +} diff --git a/src/features/studio/ui/channelFormat/FormatSelectGrid.tsx b/src/features/studio/ui/channelFormat/FormatSelectGrid.tsx new file mode 100644 index 0000000..9cc807f --- /dev/null +++ b/src/features/studio/ui/channelFormat/FormatSelectGrid.tsx @@ -0,0 +1,30 @@ +import type { ChannelFormatOption, ContentFormat } from "@/features/studio/types/studio"; +import { FormatCard } from "@/features/studio/ui/channelFormat/FormatCard"; + +interface FormatSelectGridProps { + option: ChannelFormatOption; + selectedFormat: ContentFormat | null; + onFormatSelect: (format: ContentFormat | null) => void; +} + +export function FormatSelectGrid({ option, selectedFormat, onFormatSelect }: FormatSelectGridProps) { + return ( +
+

포맷 선택

+

+ {option.label}에 적합한 콘텐츠 포맷을 선택하세요 +

+ +
+ {option.formats.map((fmt) => ( + + ))} +
+
+ ); +} diff --git a/src/features/studio/ui/generate/OutputTypeTabs.tsx b/src/features/studio/ui/generate/OutputTypeTabs.tsx new file mode 100644 index 0000000..6a97fa4 --- /dev/null +++ b/src/features/studio/ui/generate/OutputTypeTabs.tsx @@ -0,0 +1,38 @@ +import type { GenerateOutputType } from "@/features/studio/types/studio"; +import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons"; + +interface OutputTypeTabsProps { + outputType: GenerateOutputType; + onOutputTypeChange: (type: GenerateOutputType) => void; +} + +const TABS = [ + { key: "image" as const, label: "이미지 생성", Icon: FileTextFilled }, + { key: "video" as const, label: "영상 생성", Icon: VideoFilled }, +]; + +export function OutputTypeTabs({ outputType, onOutputTypeChange }: OutputTypeTabsProps) { + return ( +
+ {TABS.map((tab) => ( + + ))} +
+ ); +} diff --git a/src/features/studio/ui/generate/PreviewArea.tsx b/src/features/studio/ui/generate/PreviewArea.tsx new file mode 100644 index 0000000..dd90460 --- /dev/null +++ b/src/features/studio/ui/generate/PreviewArea.tsx @@ -0,0 +1,112 @@ +import { motion } from "motion/react"; +import type { GenerateOutputType } from "@/features/studio/types/studio"; +import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons"; +import type { GenerateResult } from "@/features/studio/services/generateImage"; + +type GenerateStatus = "idle" | "generating" | "done" | "error"; + +interface PreviewAreaProps { + status: GenerateStatus; + result: GenerateResult | null; + errorMsg: string; + outputType: GenerateOutputType; + channelLabel: string; + formatLabel: string; + aspectRatio: string; + aspectClass: string; +} + +export function PreviewArea({ + status, + result, + errorMsg, + outputType, + channelLabel, + formatLabel, + aspectRatio, + aspectClass, +}: PreviewAreaProps) { + return ( +
+

프리뷰

+ +
+ {/* Idle */} + {status === "idle" && ( +
+
+ {outputType === "image" ? ( + + ) : ( + + )} +
+

설정을 확인하고 생성 버튼을 눌러주세요

+
+ )} + + {/* Generating */} + {status === "generating" && ( +
+
+

+ {outputType === "image" ? "이미지" : "영상"} 생성 중... +

+

AI가 콘텐츠를 제작하고 있습니다

+
+ )} + + {/* Error */} + {status === "error" && ( +
+
+ + + + +
+

{errorMsg}

+
+ )} + + {/* Done — with image */} + {status === "done" && result?.imageDataUrl && ( + + )} + + {/* Done — video placeholder */} + {status === "done" && !result?.imageDataUrl && ( + +
+ + + +
+

생성 완료

+

+ {channelLabel} {formatLabel} + {outputType === "image" ? " 이미지" : " 영상"}이 준비되었습니다 +

+
+ {aspectRatio} | {outputType === "image" ? "PNG" : "MP4"} +
+
+ )} +
+
+ ); +} diff --git a/src/features/studio/ui/generate/SettingsSummary.tsx b/src/features/studio/ui/generate/SettingsSummary.tsx new file mode 100644 index 0000000..8748eae --- /dev/null +++ b/src/features/studio/ui/generate/SettingsSummary.tsx @@ -0,0 +1,104 @@ +import type { GenerateOutputType } from "@/features/studio/types/studio"; +import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons"; +import type { GenerateResult } from "@/features/studio/services/generateImage"; + +type GenerateStatus = "idle" | "generating" | "done" | "error"; + +interface SettingsSummaryProps { + channelLabel: string; + formatLabel: string; + aspectRatio: string; + trackName: string; + narrationValue: string; + subtitleValue: string; + outputType: GenerateOutputType; + status: GenerateStatus; + result: GenerateResult | null; + errorMsg: string; + onGenerate: () => void; + onReset: () => void; + onDownload: () => void; +} + +function SummaryRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function SettingsSummary({ + channelLabel, + formatLabel, + aspectRatio, + trackName, + narrationValue, + subtitleValue, + outputType, + status, + result, + errorMsg, + onGenerate, + onReset, + onDownload, +}: SettingsSummaryProps) { + return ( +
+

설정 요약

+
+ + + + + + + +
+ + {/* Generate / Retry button */} + {(status === "idle" || status === "error") && ( + + )} + + {/* Error message */} + {status === "error" && ( +

{errorMsg}

+ )} + + {/* Done actions */} + {status === "done" && ( +
+ {result?.imageDataUrl && ( + + )} + +
+ )} +
+ ); +} diff --git a/src/features/studio/ui/index.ts b/src/features/studio/ui/index.ts new file mode 100644 index 0000000..82e20fa --- /dev/null +++ b/src/features/studio/ui/index.ts @@ -0,0 +1,14 @@ +// ─── features/studio/ui barrel export ────────────────────────────────────── +export { StudioWizardProvider } from "@/features/studio/ui/StudioWizardProvider"; +export { StudioProgressBar } from "@/features/studio/ui/StudioProgressBar"; + +// ── 위저드 공용 섹션 +export { StudioWizardHeaderSection } from "@/features/studio/ui/StudioWizardHeaderSection"; +export { StudioWizardFooterSection } from "@/features/studio/ui/StudioWizardFooterSection"; + +// ── 스텝 섹션 +export { StudioChannelFormatSection } from "@/features/studio/ui/StudioChannelFormatSection"; +export { StudioStrategySourceSection } from "@/features/studio/ui/StudioStrategySourceSection"; +export { StudioSoundSection } from "@/features/studio/ui/StudioSoundSection"; +export { StudioGenerateSection } from "@/features/studio/ui/StudioGenerateSection"; +export { StudioBlogEditorSection } from "@/features/studio/ui/StudioBlogEditorSection"; diff --git a/src/features/studio/ui/sound/GenreChipList.tsx b/src/features/studio/ui/sound/GenreChipList.tsx new file mode 100644 index 0000000..ce7e980 --- /dev/null +++ b/src/features/studio/ui/sound/GenreChipList.tsx @@ -0,0 +1,29 @@ +import type { MusicGenre } from "@/features/studio/types/studio"; +import { GENRE_OPTIONS } from "@/features/studio/content/sound"; + +interface GenreChipListProps { + selectedGenre: MusicGenre; + onGenreChange: (genre: MusicGenre) => void; +} + +export function GenreChipList({ selectedGenre, onGenreChange }: GenreChipListProps) { + return ( +
+ {GENRE_OPTIONS.map((g) => ( + + ))} +
+ ); +} diff --git a/src/features/studio/ui/sound/NarrationSettings.tsx b/src/features/studio/ui/sound/NarrationSettings.tsx new file mode 100644 index 0000000..1e525fe --- /dev/null +++ b/src/features/studio/ui/sound/NarrationSettings.tsx @@ -0,0 +1,78 @@ +import type { NarrationLanguage, NarrationVoice } from "@/features/studio/types/studio"; +import { LANGUAGE_OPTIONS } from "@/features/studio/content/sound"; +import { MessageFilled } from "@/components/icons/FilledIcons"; + +interface NarrationSettingsProps { + language: NarrationLanguage; + voice: NarrationVoice; + onLanguageChange: (lang: NarrationLanguage) => void; + onVoiceChange: (voice: NarrationVoice) => void; +} + +const VOICE_OPTIONS: { key: NarrationVoice; label: string }[] = [ + { key: "female", label: "Female" }, + { key: "male", label: "Male" }, +]; + +export function NarrationSettings({ + language, + voice, + onLanguageChange, + onVoiceChange, +}: NarrationSettingsProps) { + return ( +
+ + {/* 언어 선택 */} +
+

언어

+
+ {LANGUAGE_OPTIONS.map((lang) => ( + + ))} +
+
+ + {/* 보이스 선택 */} +
+

보이스

+
+ {VOICE_OPTIONS.map((v) => ( + + ))} +
+
+ +
+ ); +} diff --git a/src/features/studio/ui/sound/ToggleSwitch.tsx b/src/features/studio/ui/sound/ToggleSwitch.tsx new file mode 100644 index 0000000..aa65073 --- /dev/null +++ b/src/features/studio/ui/sound/ToggleSwitch.tsx @@ -0,0 +1,26 @@ +interface ToggleSwitchProps { + enabled: boolean; + onToggle: (next: boolean) => void; + label: string; +} + +export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) { + return ( + + ); +} diff --git a/src/features/studio/ui/sound/TrackCard.tsx b/src/features/studio/ui/sound/TrackCard.tsx new file mode 100644 index 0000000..3b0b116 --- /dev/null +++ b/src/features/studio/ui/sound/TrackCard.tsx @@ -0,0 +1,40 @@ +import type { MusicTrack } from "@/features/studio/types/studio"; +import { MusicFilled } from "@/components/icons/FilledIcons"; + +interface TrackCardProps { + track: MusicTrack; + isSelected: boolean; + onSelect: (trackId: string | null) => void; +} + +export function TrackCard({ track, isSelected, onSelect }: TrackCardProps) { + return ( + + ); +} diff --git a/src/features/studio/ui/sound/TrackSelectGrid.tsx b/src/features/studio/ui/sound/TrackSelectGrid.tsx new file mode 100644 index 0000000..cc94d66 --- /dev/null +++ b/src/features/studio/ui/sound/TrackSelectGrid.tsx @@ -0,0 +1,28 @@ +import type { MusicGenre } from "@/features/studio/types/studio"; +import { MOCK_MUSIC_TRACKS } from "@/features/studio/mocks/musicTracks"; +import { TrackCard } from "@/features/studio/ui/sound/TrackCard"; + +interface TrackSelectGridProps { + genre: MusicGenre; + selectedTrackId: string | null; + onTrackChange: (trackId: string | null) => void; +} + +export function TrackSelectGrid({ genre, selectedTrackId, onTrackChange }: TrackSelectGridProps) { + if (genre === "none") return null; + + const tracks = MOCK_MUSIC_TRACKS.filter((t) => t.genre === genre); + + return ( +
+ {tracks.map((track) => ( + + ))} +
+ ); +} diff --git a/src/features/studio/ui/strategySource/PillarCard.tsx b/src/features/studio/ui/strategySource/PillarCard.tsx new file mode 100644 index 0000000..6e2ccb4 --- /dev/null +++ b/src/features/studio/ui/strategySource/PillarCard.tsx @@ -0,0 +1,43 @@ +import type { ContentPillar, ContentPillarId } from "@/features/studio/types/studio"; + +interface PillarCardProps { + pillar: ContentPillar; + isSelected: boolean; + onSelect: (id: ContentPillarId) => void; +} + +export function PillarCard({ pillar, isSelected, onSelect }: PillarCardProps) { + return ( + + ); +} diff --git a/src/features/studio/ui/strategySource/PillarSelectList.tsx b/src/features/studio/ui/strategySource/PillarSelectList.tsx new file mode 100644 index 0000000..e30f347 --- /dev/null +++ b/src/features/studio/ui/strategySource/PillarSelectList.tsx @@ -0,0 +1,28 @@ +import type { ContentPillarId } from "@/features/studio/types/studio"; +import { MOCK_CONTENT_PILLARS } from "@/features/studio/mocks/contentPillars"; +import { PillarCard } from "@/features/studio/ui/strategySource/PillarCard"; + +interface PillarSelectListProps { + selectedPillarId: ContentPillarId | null; + onPillarSelect: (id: ContentPillarId) => void; +} + +export function PillarSelectList({ selectedPillarId, onPillarSelect }: PillarSelectListProps) { + return ( +
+

콘텐츠 전략

+

콘텐츠의 핵심 메시지 필러를 선택하세요

+ +
+ {MOCK_CONTENT_PILLARS.map((pillar) => ( + + ))} +
+
+ ); +} diff --git a/src/features/studio/ui/strategySource/SourceCard.tsx b/src/features/studio/ui/strategySource/SourceCard.tsx new file mode 100644 index 0000000..e7bffb7 --- /dev/null +++ b/src/features/studio/ui/strategySource/SourceCard.tsx @@ -0,0 +1,47 @@ +import type { AssetSourceType } from "@/features/studio/types/studio"; + +interface SourceCardProps { + source: { + key: AssetSourceType; + title: string; + description: string; + }; + isSelected: boolean; + onToggle: (source: AssetSourceType) => void; +} + +export function SourceCard({ source, isSelected, onToggle }: SourceCardProps) { + return ( + + ); +} diff --git a/src/features/studio/ui/strategySource/SourceSelectList.tsx b/src/features/studio/ui/strategySource/SourceSelectList.tsx new file mode 100644 index 0000000..2c1b4e9 --- /dev/null +++ b/src/features/studio/ui/strategySource/SourceSelectList.tsx @@ -0,0 +1,33 @@ +import type { AssetSourceType } from "@/features/studio/types/studio"; +import { SourceCard } from "@/features/studio/ui/strategySource/SourceCard"; + +const ASSET_SOURCE_OPTIONS: { key: AssetSourceType; title: string; description: string }[] = [ + { key: "collected", title: "수집된 에셋", description: "홈페이지, 블로그, SNS에서 수집한 기존 에셋" }, + { key: "my_assets", title: "My Assets", description: "직접 업로드한 이미지, 영상, 텍스트 파일" }, + { key: "ai_generated", title: "AI 생성", description: "AI가 새로 생성하는 이미지, 텍스트, 영상" }, +]; + +interface SourceSelectListProps { + selectedSources: AssetSourceType[]; + onSourceToggle: (source: AssetSourceType) => void; +} + +export function SourceSelectList({ selectedSources, onSourceToggle }: SourceSelectListProps) { + return ( +
+

소스 선택

+

콘텐츠에 사용할 에셋 소스를 선택하세요 (복수 선택)

+ +
+ {ASSET_SOURCE_OPTIONS.map((source) => ( + + ))} +
+
+ ); +} diff --git a/src/layouts/PageNavigator.tsx b/src/layouts/PageNavigator.tsx index e815e48..15459b5 100644 --- a/src/layouts/PageNavigator.tsx +++ b/src/layouts/PageNavigator.tsx @@ -30,7 +30,7 @@ const PAGE_FLOW: FlowStep[] = [ navigatePath: DEFAULT_PLAN_NAV_PATH, isActive: (p) => p === "/plan" || p.startsWith("/plan/"), }, - { id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" }, + { id: "studio", label: "콘텐츠 제작", navigatePath: "/studio/demo", isActive: (p) => p === "/studio" || p.startsWith("/studio/") }, { id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" }, { id: "distribute", diff --git a/src/pages/Studio.tsx b/src/pages/Studio.tsx new file mode 100644 index 0000000..c94198b --- /dev/null +++ b/src/pages/Studio.tsx @@ -0,0 +1,18 @@ +import { StudioWizardProvider } from "@/features/studio/ui/StudioWizardProvider"; +import { StudioWizardHeaderSection } from "@/features/studio/ui/StudioWizardHeaderSection"; +import { StudioWizardFooterSection } from "@/features/studio/ui/StudioWizardFooterSection"; +import { StudioStepAnimator } from "@/features/studio/ui/StudioStepAnimator"; + +export function StudioPage() { + return ( + +
+
+ + + +
+
+
+ ); +}