diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7e55be3..174a9e8 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -67,12 +67,16 @@ export default defineConfig({ server: { hmr: process.env.DISABLE_HMR !== 'true', watch: process.env.DISABLE_HMR === 'true' ? null : {}, - // Proxy API calls to the embedded Express server + // Proxy API and Workspace calls to the embedded Express server proxy: { '/api': { target: 'http://127.0.0.1:3000', changeOrigin: true, }, + '/workspace': { + target: 'http://127.0.0.1:3000', + changeOrigin: true, + }, }, }, }, diff --git a/package-lock.json b/package-lock.json index 9b7a49b..ffdd15a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", + "better-sqlite3": "^12.10.0", + "cors": "^2.8.6", "dotenv": "^17.2.3", "express": "^4.21.2", "file-saver": "^2.0.5", @@ -31,6 +33,8 @@ "@electron-forge/cli": "^7.11.2", "@electron-forge/maker-dmg": "^7.11.2", "@electron-forge/maker-squirrel": "^7.11.2", + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/file-saver": "^2.0.7", "@types/multer": "^2.1.0", @@ -2907,6 +2911,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2941,6 +2955,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3968,6 +3992,20 @@ "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -3977,11 +4015,19 @@ "node": "*" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4116,7 +4162,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -4709,6 +4754,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -4761,7 +4823,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -4777,7 +4838,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4786,6 +4846,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -5580,6 +5649,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5816,6 +5894,12 @@ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filename-reserved-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", @@ -6019,6 +6103,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -6279,6 +6369,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6636,7 +6732,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -7961,7 +8056,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8097,6 +8191,12 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/motion": { "version": "12.40.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", @@ -8212,6 +8312,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -8248,7 +8354,6 @@ "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "dev": true, "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -8261,7 +8366,6 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8392,6 +8496,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -8929,6 +9042,67 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -9212,6 +9386,27 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -9880,6 +10075,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -10176,6 +10416,15 @@ "node": ">=0.10.0" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-outer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", @@ -11043,6 +11292,18 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", diff --git a/package.json b/package.json index 6f2ffc9..b8940d5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", + "better-sqlite3": "^12.10.0", + "cors": "^2.8.6", "dotenv": "^17.2.3", "express": "^4.21.2", "file-saver": "^2.0.5", @@ -42,6 +44,8 @@ "@electron-forge/cli": "^7.11.2", "@electron-forge/maker-dmg": "^7.11.2", "@electron-forge/maker-squirrel": "^7.11.2", + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/file-saver": "^2.0.7", "@types/multer": "^2.1.0", diff --git a/server.ts b/server.ts index 5c7df17..0ffe191 100644 --- a/server.ts +++ b/server.ts @@ -4,6 +4,7 @@ import fs from "fs"; import crypto from "crypto"; import { createServer as createViteServer } from "vite"; import multer from "multer"; +import cors from "cors"; // ═══ Uploads directory ═══ const UPLOADS_DIR = process.env.BRADLY_UPLOADS_DIR || path.join(process.cwd(), "uploads"); @@ -51,6 +52,9 @@ export async function createExpressApp() { const app = express(); const PORT = 3000; + // Add CORS + app.use(cors()); + // Add JSON parser with generous limit for render payloads (timelineElements can be large) app.use(express.json({ limit: '50mb' })); @@ -81,12 +85,12 @@ export async function createExpressApp() { app.post("/api/upload/brand", mediaUpload.single("file"), (req, res) => { try { if (!req.file) return res.status(400).json({ error: "No file uploaded" }); - const { brandId, workspacePath } = req.body; + const { brandId, workspacePath, subfolder = "brand" } = req.body; if (!brandId || !workspacePath) { return res.status(400).json({ error: "brandId and workspacePath required" }); } - const brandDir = path.join(workspacePath, brandId, "brand"); + const brandDir = path.join(workspacePath, brandId, subfolder); if (!fs.existsSync(brandDir)) { fs.mkdirSync(brandDir, { recursive: true }); } @@ -99,7 +103,7 @@ export async function createExpressApp() { fs.renameSync(req.file.path, finalPath); // Return the public URL that routes through our dynamic /workspace middleware - const publicUrl = `http://localhost:3000/workspace/${brandId}/brand/${finalFilename}`; + const publicUrl = `http://localhost:3000/workspace/${brandId}/${subfolder}/${finalFilename}`; res.json({ url: publicUrl, path: finalPath }); } catch (error) { console.error("Brand upload error:", error); diff --git a/src/App.tsx b/src/App.tsx index b785afa..cbbb65d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -165,7 +165,7 @@ export default function App() { const handleEditAsset = useCallback(async (assetInfo: { type: keyof DesignMD; url: string }) => { try { - const isVideo = assetInfo.type === 'introVideoUrl' || assetInfo.type === 'outroVideoUrl'; + const isVideo = assetInfo.url.toLowerCase().endsWith('.mp4') || assetInfo.url.toLowerCase().endsWith('.webm'); const dimensions = await detectMediaDimensionsAndAspect(assetInfo.url, isVideo ? 'video' : 'image'); const layerId = 'layer-1'; diff --git a/src/components/BrandArchitecture.tsx b/src/components/BrandArchitecture.tsx index 05ad17d..ca96225 100644 --- a/src/components/BrandArchitecture.tsx +++ b/src/components/BrandArchitecture.tsx @@ -1,13 +1,16 @@ -import React, { useState, useCallback } from 'react'; -import { Save, AlertCircle, Crown, FolderOpen, Sparkles } from 'lucide-react'; -import { DesignMD, CompanyProfile } from '../types'; +import React, { useState, useCallback, useEffect } from 'react'; +import { Save, AlertCircle, Crown, FolderOpen, Sparkles, Image as ImageIcon, Film, Volume2, Music } from 'lucide-react'; +import { CustomVideoPlayer } from './ui/CustomVideoPlayer'; +import { useMediaResolver } from '../hooks/useMediaResolver'; +import { DesignMD, CompanyProfile, BrandAsset } from '../types'; import { BrandTabGeneral } from './brand/BrandTabGeneral'; import { BrandTabVisual } from './brand/BrandTabVisual'; import { BrandTabTypography } from './brand/BrandTabTypography'; import { BrandTabMedia } from './brand/BrandTabMedia'; -import { BrandTabGenerated } from './brand/BrandTabGenerated'; +import { BrandTabVoice } from './brand/BrandTabVoice'; import { BrandPreview } from './brand/BrandPreview'; import { Toast } from './ui/Toast'; +import { UnifiedMediaItem } from './content-grid/UnifiedMediaLibrary'; interface BrandArchitectureProps { company: CompanyProfile; @@ -22,8 +25,8 @@ const TABS = [ { id: 'general', label: 'Información', icon: '📋' }, { id: 'visual', label: 'Visual y Colores', icon: '🎨' }, { id: 'typography', label: 'Tipografía', icon: '🔤' }, - { id: 'media', label: 'Video y Audio', icon: '🎬' }, - { id: 'generated', label: 'Generados', icon: '✨' }, + { id: 'voice', label: 'Voz de Marca', icon: '🎙️' }, + { id: 'media', label: 'Librería', icon: '📁' }, ] as const; type TabId = typeof TABS[number]['id']; @@ -34,7 +37,9 @@ export const BrandArchitecture: React.FC = ({ company, h const [activeTab, setActiveTab] = useState('general'); const [showToast, setShowToast] = useState(false); const [validationErrors, setValidationErrors] = useState([]); + const [selectedMediaItem, setSelectedMediaItem] = useState(null); + const { getMediaUrl } = useMediaResolver(); const validate = useCallback((): string[] => { const errors: string[] = []; if (!company?.name || company.name.trim().length < 2) { @@ -63,8 +68,8 @@ export const BrandArchitecture: React.FC = ({ company, h const handleOpenFolder = async () => { if (window.electronAPI && company?.id) { - const workspacePath = await window.electronAPI.fs.getWorkspacePath(); - const folderPath = `${workspacePath}/${company.id}`; + const wp = await window.electronAPI.fs.getWorkspacePath(); + const folderPath = `${wp}/${company.id}`; await window.electronAPI.fs.openFolder(folderPath); } }; @@ -210,26 +215,70 @@ export const BrandArchitecture: React.FC = ({ company, h {activeTab === 'typography' && ( )} + {activeTab === 'voice' && ( + + )} {activeTab === 'media' && ( )} - {activeTab === 'generated' && ( - - )} {/* Preview Column */} - {activeTab === 'generated' ? ( -
- -

Selecciona un archivo generado para previsualizarlo.

+ {activeTab === 'media' ? ( +
+ {selectedMediaItem ? ( +
+
+ {selectedMediaItem.type === 'video' ? ( + + ) : selectedMediaItem.type === 'audio' ? ( +
+
+ +
+
+ ) : ( + {selectedMediaItem.id} + )} +
+
+

+ {selectedMediaItem.id} +

+

+ {'date' in selectedMediaItem && selectedMediaItem.date ? new Date((selectedMediaItem as any).date).toLocaleString() : 'Asset Base'} +

+
+
+ ) : ( +
+ +

Selecciona un archivo para previsualizarlo.

+
+ )}
) : ( = ({ titleOverride, }) => { const [menuOpen, setMenuOpen] = useState(false); + const [showAISettings, setShowAISettings] = useState(false); const isStudio = currentStep === 'studio'; return ( @@ -86,11 +88,22 @@ export const TopHeader: React.FC = ({ > Abrir +
+
)}
+ {showAISettings && ( + setShowAISettings(false)} /> + )} +
diff --git a/src/components/ai/CopyAssistant.tsx b/src/components/ai/CopyAssistant.tsx new file mode 100644 index 0000000..0056b3a --- /dev/null +++ b/src/components/ai/CopyAssistant.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import { Sparkles, RefreshCcw, Undo2, Check, AlertCircle } from 'lucide-react'; +import { Platform } from '../../types'; + +interface CopyAssistantProps { + brandId: string; + platforms: Platform[]; + description: string; + previousCaption?: string; + onApplyCopy: (copy: string) => void; + onApplyHashtags: (hashtags: string[]) => void; + onUndo: () => void; +} + +export const CopyAssistant: React.FC = ({ + brandId, + platforms, + description, + previousCaption, + onApplyCopy, + onApplyHashtags, + onUndo +}) => { + const [isGenerating, setIsGenerating] = useState(false); + const [result, setResult] = useState<{ copy: string; hashtags: string[] } | null>(null); + const [error, setError] = useState(null); + + const handleGenerate = async (isRetry = false) => { + if (!description.trim()) { + setError('Escribe una descripción primero para tener contexto.'); + return; + } + + setIsGenerating(true); + setError(null); + try { + if (window.electronAPI?.ai) { + // En caso de regenerar, podríamos añadir una pequeña instrucción extra, o simplemente + // volver a lanzar el prompt (con temperatura > 0 ya generará algo distinto). + const promptToUse = isRetry + ? `${description}\n\n[INSTRUCCIÓN ADICIONAL]: Genera una variación diferente a la anterior, tal vez con otro enfoque o gancho.` + : description; + + const res = await window.electronAPI.ai.generateCopy({ + brandId, + userPrompt: promptToUse, + platforms, + purpose: 'caption' + }); + + if (res.success && res.data) { + setResult({ + copy: res.data.copy || '', + hashtags: res.data.hashtags || [] + }); + } else { + setError(res.error || 'Error al generar.'); + } + } + } catch (err: any) { + setError(err.message || 'Error de conexión.'); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+
+
+ Asistente de Copy IA +
+ +
+ {previousCaption && ( + + )} + + +
+
+ + {error && ( +
+ +

{error}

+
+ )} + + {result && !isGenerating && ( +
+
+
+ Sugerencia de Copy + +
+
+ {result.copy} +
+
+ + {result.hashtags.length > 0 && ( +
+
+ Hashtags + +
+
+ {result.hashtags.map((tag, i) => ( + + #{tag} + + ))} +
+
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/brand/BrandTabGenerated.tsx b/src/components/brand/BrandTabGenerated.tsx deleted file mode 100644 index b4573c0..0000000 --- a/src/components/brand/BrandTabGenerated.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { CompanyProfile } from '../../types'; -import { GeneratedMediaList } from '../content-grid/GeneratedMediaList'; - -interface BrandTabGeneratedProps { - company: CompanyProfile; -} - -export const BrandTabGenerated: React.FC = ({ company }) => { - return ( -
-
-

- Contenido Generado -

-

- Archivos renderizados y guardados para la marca {company.name}. -

-
- -
- ); -}; diff --git a/src/components/brand/BrandTabMedia.tsx b/src/components/brand/BrandTabMedia.tsx index 56b79a6..24b4994 100644 --- a/src/components/brand/BrandTabMedia.tsx +++ b/src/components/brand/BrandTabMedia.tsx @@ -1,388 +1,185 @@ -import React, { useCallback } from 'react'; -import { Film, Volume2, Music, X, Upload, Wand2, Maximize2, Minimize2, Move, Pipette } from 'lucide-react'; -import { DesignMD, CompanyProfile } from '../../types'; -import { FileDropZone } from '../ui/FileDropZone'; +import React, { useState } from 'react'; +import { Film } from 'lucide-react'; +import { DesignMD, CompanyProfile, BrandAsset } from '../../types'; + +import { UnifiedMediaLibrary, UnifiedMediaItem } from '../content-grid/UnifiedMediaLibrary'; + +type AssetFilter = 'all' | 'image' | 'video' | 'audio'; +type SourceFilter = 'all' | 'uploaded' | 'generated'; interface BrandTabMediaProps { company: CompanyProfile; designMD: DesignMD; - handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void; + handleDesignChange: (key: keyof DesignMD, value: any) => void; + onEditAsset?: (type: keyof DesignMD, url: string) => void; + onSelectAsset?: (asset: BrandAsset) => void; + selectedAssetId?: string; } -/** - * BrandTabMedia — Upload-only panel for brand video/audio assets. - * - * Only handles uploading the intro video, outro video, and brand audio. - * All positioning, fit, duration, and blend controls live in the TemplateBuilder - * (per-template segment configuration), avoiding collisions. - */ -export const BrandTabMedia: React.FC void }> = ({ company, designMD, handleDesignChange, onEditAsset }) => { +export const BrandTabMedia: React.FC = ({ company, designMD, handleDesignChange, onSelectAsset, selectedAssetId }) => { + const assets = designMD.brandAssets || []; + const [uploadingBrandAsset, setUploadingBrandAsset] = useState(false); + const [uploadingGenerated, setUploadingGenerated] = useState(false); + const [filterType, setFilterType] = useState('all'); + const [filterSource, setFilterSource] = useState('all'); + const [refreshTrigger, setRefreshTrigger] = useState(0); - /** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */ - const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => { - if (!url) return; - const video = document.createElement('video'); - video.preload = 'metadata'; - video.onloadedmetadata = () => { - if (video.duration && isFinite(video.duration)) { - const frames = Math.round(video.duration * 30); // 30fps - handleDesignChange(key, Math.max(15, Math.min(300, frames))); + const handleBrandAssetFiles = async (files: File[]) => { + if (files.length === 0) return; + setUploadingBrandAsset(true); + try { + let currentWorkspacePath = ''; + if (window.electronAPI) { + currentWorkspacePath = await window.electronAPI.fs.getWorkspacePath(); } - video.remove(); - }; - video.onerror = () => video.remove(); - video.src = url; - }, [handleDesignChange]); + + const newAssets = [...assets]; + + for (const file of files) { + const baseName = file.name.split('.')[0].replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase(); + let uniqueId = baseName; + let counter = 1; + while (newAssets.some(a => a.id === uniqueId)) { + uniqueId = `${baseName}-${counter}`; + counter++; + } + + const formData = new FormData(); + formData.append('file', file); + + let type: 'image' | 'video' | 'audio' = 'image'; + let subfolder = 'files/images'; + if (file.type.startsWith('video/')) { + type = 'video'; + subfolder = 'files/videos'; + } else if (file.type.startsWith('audio/')) { + type = 'audio'; + subfolder = 'files/audios'; + } + + if (currentWorkspacePath && company.id) { + formData.append('brandId', company.id); + formData.append('workspacePath', currentWorkspacePath); + formData.append('subfolder', subfolder); + } + + const endpoint = (currentWorkspacePath && company.id) ? '/api/upload/brand' : '/api/upload'; + const res = await fetch(endpoint, { method: 'POST', body: formData }); + + if (!res.ok) throw new Error('Upload failed'); + const data = await res.json(); + + if (data.url) { + newAssets.push({ + id: uniqueId, + type, + url: data.url, + path: data.path + }); + } + } + + handleDesignChange('brandAssets', newAssets); + } catch (err) { + console.error('Asset upload failed:', err); + alert('Ocurrió un error al subir los archivos.'); + } finally { + setUploadingBrandAsset(false); + } + }; + + const handleDeleteAsset = (id: string) => { + if (confirm(`¿Estás seguro de que deseas eliminar el asset "${id}"?`)) { + handleDesignChange('brandAssets', assets.filter(a => a.id !== id)); + } + }; + + const handleRenameAsset = (oldId: string, newId: string) => { + if (newId !== oldId && assets.some(a => a.id === newId)) { + alert('Este identificador ya existe.'); + return; + } + + const newAssets = assets.map(a => { + if (a.id === oldId) return { ...a, id: newId }; + return a; + }); + handleDesignChange('brandAssets', newAssets); + }; + + const onSelectUnified = (item: UnifiedMediaItem) => { + if (!onSelectAsset) return; + // Map UnifiedMediaItem back to BrandAsset if it's uploaded + if (item.source === 'uploaded') { + const asset = assets.find(a => a.id === item.id); + if (asset) onSelectAsset(asset); + } else { + // It's generated. We can construct a mock BrandAsset to show in preview + onSelectAsset({ + id: item.name || item.id, + type: item.type, + path: item.path, + url: item.url, + date: item.date + }); + } + }; return ( -
- {/* Section title */} -
-

- - Archivos de Video y Audio -

-

- Sube los videos y audio de tu marca. La posición, duración y estilo se configuran en cada plantilla. -

+
+ {/* Encabezado */} +
+
+

+ + Librería Unificada +

+

+ Gestiona tus archivos base de marca y tus renders generados en un solo lugar. +

+
+ +
+ + + +
- {/* ═══ Intro Video ═══ */} - { - handleDesignChange('introVideoUrl', url); - if (url) probeVideoDuration(url, 'introDurationFrames'); - }} - onClear={() => { - handleDesignChange('introVideoUrl', ''); - handleDesignChange('introDurationFrames', 60); - }} - onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')} - showEdit={!!(designMD.introVideoUrl && onEditAsset)} - fit={designMD.introVideoFit} - onFitChange={(fit) => handleDesignChange('introVideoFit', fit)} - bgColor={designMD.introVideoBgColor} - onBgColorChange={(color) => handleDesignChange('introVideoBgColor', color ?? '')} - /> - - {/* ═══ Outro Video ═══ */} - { - handleDesignChange('outroVideoUrl', url); - if (url) probeVideoDuration(url, 'outroDurationFrames'); - }} - onClear={() => { - handleDesignChange('outroVideoUrl', ''); - handleDesignChange('outroDurationFrames', 60); - }} - onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')} - showEdit={!!(designMD.outroVideoUrl && onEditAsset)} - fit={designMD.outroVideoFit} - onFitChange={(fit) => handleDesignChange('outroVideoFit', fit)} - bgColor={designMD.outroVideoBgColor} - onBgColorChange={(color) => handleDesignChange('outroVideoBgColor', color ?? '')} - /> - - {/* ═══ Brand Audio ═══ */} -
-
- - {designMD.brandAudioUrl && ( - - )} -
- -

- Se incluirá como pista de fondo en plantillas de video -

- -
- {/* Preview */} -
- {designMD.brandAudioUrl ? ( -
- {[3, 5, 4, 6, 3].map((h, i) => ( -
- ))} -
- ) : ( - - )} -
- - {/* Upload controls */} -
- handleDesignChange('brandAudioUrl', e.target.value)} - className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white" - placeholder="https://audio.mp3" - /> - { - let workspacePath = ''; - if (window.electronAPI) { - workspacePath = await window.electronAPI.fs.getWorkspacePath(); - } - - const formData = new FormData(); - formData.append('file', files[0]); - - try { - let res; - if (workspacePath && company.id) { - formData.append('brandId', company.id); - formData.append('workspacePath', workspacePath); - res = await fetch('/api/upload/brand', { method: 'POST', body: formData }); - } else { - res = await fetch('/api/upload', { method: 'POST', body: formData }); - } - - if (!res.ok) throw new Error('Upload failed'); - const data = await res.json(); - if (data.url) handleDesignChange('brandAudioUrl', data.url); - } catch (err) { - console.error('Audio upload failed:', err); - } - }} - /> -
-
- - {/* Volume slider */} - {designMD.brandAudioUrl && ( -
- - Volumen: - handleDesignChange('brandAudioVolume', parseInt(e.target.value) / 100)} - className="flex-1 h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500" - /> - - {Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}% - -
- )} + {/* Unified Media Library as Global Drop Zone */} +
+
); }; - -/* ── Simple Video Upload Card ── */ - -const VideoUploadSimple: React.FC<{ - company: CompanyProfile; - label: string; - description: string; - videoUrl: string; - accentColor: string; - onUrlChange: (url: string) => void; - onClear: () => void; - onEdit?: () => void; - showEdit?: boolean; - fit?: 'cover' | 'contain' | 'fill'; - onFitChange?: (fit: 'cover' | 'contain' | 'fill') => void; - bgColor?: string | null; - onBgColorChange?: (color: string | null) => void; -}> = ({ company, label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit, fit = 'cover', onFitChange, bgColor, onBgColorChange }) => { - const hasVideo = !!videoUrl && videoUrl.trim().length > 0; - const colorInputRef = React.useRef(null); - - return ( -
-
- - {hasVideo && ( - - )} -
- -

{description}

- -
- {/* Video Preview */} -
- {hasVideo ? ( -
- - {/* Controls */} -
- onUrlChange(e.target.value)} - className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white" - placeholder="https://video.mp4" - /> - { - let workspacePath = ''; - if (window.electronAPI) { - workspacePath = await window.electronAPI.fs.getWorkspacePath(); - } - - const formData = new FormData(); - formData.append('file', files[0]); - - try { - let res; - if (workspacePath && company.id) { - formData.append('brandId', company.id); - formData.append('workspacePath', workspacePath); - res = await fetch('/api/upload/brand', { method: 'POST', body: formData }); - } else { - res = await fetch('/api/upload', { method: 'POST', body: formData }); - } - - if (!res.ok) throw new Error('Upload failed'); - const data = await res.json(); - if (data.url) onUrlChange(data.url); - } catch (err) { - console.error('Video upload failed:', err); - } - }} - /> - {showEdit && ( - - )} -
-
- - {/* Status badge */} - {hasVideo && ( -
-
- Ajuste de video: - {([ - { key: 'cover' as const, label: 'Cover', icon: , tip: 'Llenar pantalla' }, - { key: 'contain' as const, label: 'Contain', icon: , tip: 'Mostrar completo' }, - { key: 'fill' as const, label: 'Fill', icon: , tip: 'Estirar' }, - ]).map(opt => ( - - ))} -
- - {fit === 'contain' && onBgColorChange && ( -
- Color de fondo: - - onBgColorChange(e.target.value)} - className="sr-only" - tabIndex={-1} - /> - - {bgColor && {bgColor}} -
- )} -
- )} -
- ); -}; diff --git a/src/components/brand/BrandTabVoice.tsx b/src/components/brand/BrandTabVoice.tsx new file mode 100644 index 0000000..31abf41 --- /dev/null +++ b/src/components/brand/BrandTabVoice.tsx @@ -0,0 +1,232 @@ +import React, { useState } from 'react'; +import { MessageSquareQuote, Plus, X, ListPlus } from 'lucide-react'; +import { CompanyProfile } from '../../types'; +import { CollapsibleSection } from '../ui/CollapsibleSection'; + +interface BrandTabVoiceProps { + company: CompanyProfile; + handleCompanyChange: (company: CompanyProfile) => void; +} + +export const BrandTabVoice: React.FC = ({ company, handleCompanyChange }) => { + const voice = company.brandVoice || { + communicationStyle: '', + toneKeywords: [], + personality: '', + exampleCopys: [], + avoidRules: [], + language: 'Español' + }; + + const updateVoice = (key: keyof typeof voice, value: any) => { + handleCompanyChange({ + ...company, + brandVoice: { + ...voice, + [key]: value + } + }); + }; + + const [newKeyword, setNewKeyword] = useState(''); + const [newExample, setNewExample] = useState(''); + const [newRule, setNewRule] = useState(''); + + const addKeyword = () => { + if (newKeyword.trim() && !voice.toneKeywords.includes(newKeyword.trim())) { + updateVoice('toneKeywords', [...voice.toneKeywords, newKeyword.trim()]); + setNewKeyword(''); + } + }; + + const removeKeyword = (kw: string) => { + updateVoice('toneKeywords', voice.toneKeywords.filter(k => k !== kw)); + }; + + const addExample = () => { + if (newExample.trim()) { + updateVoice('exampleCopys', [...(voice.exampleCopys || []), newExample.trim()]); + setNewExample(''); + } + }; + + const removeExample = (index: number) => { + const list = [...(voice.exampleCopys || [])]; + list.splice(index, 1); + updateVoice('exampleCopys', list); + }; + + const addRule = () => { + if (newRule.trim()) { + updateVoice('avoidRules', [...(voice.avoidRules || []), newRule.trim()]); + setNewRule(''); + } + }; + + const removeRule = (index: number) => { + const list = [...(voice.avoidRules || [])]; + list.splice(index, 1); + updateVoice('avoidRules', list); + }; + + return ( +
+ {/* Basic Setup */} +
+

+ Fundamentos de Voz +

+ +
+
+ +