Compare commits

...

22 Commits

Author SHA1 Message Date
Gustavo Henrique Santos Souza de Miranda ae0f84fde7 chore: mark US-027 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:46:36 -03:00
Gustavo Henrique Santos Souza de Miranda 7270d72fa4 feat: [US-027] - Connect nodes with edges
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:46:02 -03:00
Gustavo Henrique Santos Souza de Miranda 718180fd35 chore: mark US-026 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:44:32 -03:00
Gustavo Henrique Santos Souza de Miranda e9916d9897 feat: [US-026] - Add variable node from toolbar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:44:11 -03:00
Gustavo Henrique Santos Souza de Miranda 3453ba7492 chore: mark US-025 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:58:47 -03:00
Gustavo Henrique Santos Souza de Miranda 1ad3c183a7 feat: [US-025] - Create custom variable node component
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:58:20 -03:00
Gustavo Henrique Santos Souza de Miranda 64d67b9aed chore: mark US-024 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:56:48 -03:00
Gustavo Henrique Santos Souza de Miranda 9082e15949 feat: [US-024] - Add/remove choice options
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:56:20 -03:00
Gustavo Henrique Santos Souza de Miranda 200b998485 chore: mark US-023 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:54:57 -03:00
Gustavo Henrique Santos Souza de Miranda 4355601bdb feat: [US-023] - Add choice node from toolbar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:54:18 -03:00
Gustavo Henrique Santos Souza de Miranda 09327c2410 chore: mark US-022 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:53:14 -03:00
Gustavo Henrique Santos Souza de Miranda 91b627907b feat: [US-022] - Create custom choice node component
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:52:47 -03:00
Gustavo Henrique Santos Souza de Miranda 77c85deb1d chore: mark US-021 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:51:06 -03:00
Gustavo Henrique Santos Souza de Miranda 99e2d5aa15 feat: [US-021] - Add dialogue node from toolbar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:50:20 -03:00
Gustavo Henrique Santos Souza de Miranda 9d46aa744d chore: mark US-020 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:48:20 -03:00
Gustavo Henrique Santos Souza de Miranda e31a0b7bae feat: [US-020] - Create custom dialogue node component
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:47:35 -03:00
Gustavo Henrique Santos Souza de Miranda 037c67849a chore: mark US-019 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:45:49 -03:00
Gustavo Henrique Santos Souza de Miranda 9aa2aa48f0 feat: [US-019] - Editor toolbar
Add toolbar component with buttons for adding nodes (Dialogue, Choice,
Variable) and managing projects (Save, Export, Import). Buttons are
styled with TailwindCSS and wired to placeholder handlers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:45:11 -03:00
Gustavo Henrique Santos Souza de Miranda e530606cef chore: mark US-018 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:43:41 -03:00
Gustavo Henrique Santos Souza de Miranda 25627ddb0f feat: [US-018] - Canvas pan and zoom controls
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:43:20 -03:00
Gustavo Henrique Santos Souza de Miranda c314ae7b8c chore: mark US-017 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:42:13 -03:00
Gustavo Henrique Santos Souza de Miranda db742e7fe8 feat: [US-017] - Editor page with React Flow canvas
Implement the flowchart editor page with React Flow integration:
- Install reactflow package for canvas rendering
- Create editor page that fetches project data from Supabase
- Add loading state with spinner while fetching
- Handle unauthorized access and not found with appropriate responses
- Render React Flow canvas with grid background
- Add header with project name and back link to dashboard
- Initialize React Flow with nodes and edges from flowchart_data
- Add type converters for FlowchartNode/Edge to React Flow types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:41:08 -03:00
11 changed files with 1464 additions and 22 deletions

586
package-lock.json generated
View File

@ -1,18 +1,20 @@
{
"name": "webvnwrite-temp",
"name": "webvnwrite",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "webvnwrite-temp",
"name": "webvnwrite",
"version": "0.1.0",
"dependencies": {
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.91.0",
"nanoid": "^5.1.6",
"next": "16.1.4",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"reactflow": "^11.11.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -1228,6 +1230,108 @@
"node": ">=12.4.0"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -1618,6 +1722,259 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1625,6 +1982,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1658,7 +2021,7 @@
"version": "19.2.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -2678,6 +3041,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -2750,9 +3119,114 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -5038,9 +5512,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
@ -5049,10 +5523,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/napi-postinstall": {
@ -5131,6 +5605,24 @@
}
}
},
"node_modules/next/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -5455,6 +5947,25 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -5536,6 +6047,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -6505,6 +7034,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -6683,6 +7221,34 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@ -12,9 +12,11 @@
"dependencies": {
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.91.0",
"nanoid": "^5.1.6",
"next": "16.1.4",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"reactflow": "^11.11.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -301,7 +301,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 17,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -318,7 +318,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 18,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -335,7 +335,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -354,7 +354,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 20,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -371,7 +371,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 21,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -390,7 +390,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 22,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -408,7 +408,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 23,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -427,7 +427,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 24,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -447,7 +447,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 25,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -465,7 +465,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 26,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -483,7 +483,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 27,
"passes": false,
"passes": true,
"notes": ""
},
{

View File

@ -17,6 +17,13 @@
- Toast component in `src/components/Toast.tsx` for success/error notifications (auto-dismiss after 3s)
- Admin operations use SUPABASE_SERVICE_ROLE_KEY (server-side only via server actions)
- Admin users have is_admin=true in profiles table; check via .select('is_admin').eq('id', user.id).single()
- React Flow editor is in `src/app/editor/[projectId]/` with page.tsx (server) and FlowchartEditor.tsx (client)
- React Flow requires 'use client' and importing 'reactflow/dist/style.css'
- Use toReactFlowNodes/toReactFlowEdges helpers to convert app types to React Flow types
- Custom node components go in `src/components/editor/nodes/` with NodeProps<T> typing and useReactFlow() for updates
- Register custom node types in nodeTypes object (memoized with useMemo) and pass to ReactFlow component
- FlowchartEditor uses ReactFlowProvider wrapper + inner component pattern for useReactFlow() hook access
- Use nanoid for generating unique node IDs (import from 'nanoid')
---
@ -260,3 +267,145 @@
- Admin check should happen both in server component (redirect) and server action (double check)
- Admin page uses its own layout (not dashboard layout) to have custom styling
---
## 2026-01-21 - US-017
- What was implemented: Editor page with React Flow canvas
- Files changed:
- package.json - added reactflow dependency
- src/app/editor/[projectId]/page.tsx - new server component that fetches project from Supabase, handles auth/not found, renders header with back link
- src/app/editor/[projectId]/FlowchartEditor.tsx - new client component with React Flow canvas, Background component, type converters for nodes/edges
- src/app/editor/[projectId]/loading.tsx - new loading state component with spinner
- **Learnings for future iterations:**
- React Flow requires 'use client' directive since it uses browser APIs
- Import 'reactflow/dist/style.css' for default React Flow styling
- Use useNodesState and useEdgesState hooks for managing nodes/edges state
- Convert app types (FlowchartNode, FlowchartEdge) to React Flow types with helper functions
- Next.js dynamic route params come as Promise in App Router 16+ (need to await params)
- Use notFound() from next/navigation for 404 responses
- React Flow canvas needs parent container with explicit height (h-full, h-screen)
- Background component accepts variant (Dots, Lines, Cross) and gap/size props
- Loading page (loading.tsx) provides automatic loading UI for async server components
---
## 2026-01-21 - US-018
- What was implemented: Canvas pan and zoom controls
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added Controls import and component
- **Learnings for future iterations:**
- React Flow Controls component provides zoom +/-, fitView, and lock buttons out of the box
- Use position="bottom-right" prop to position controls in bottom-right corner
- Pan (click-and-drag) and zoom (mouse wheel) are React Flow defaults, no extra config needed
---
## 2026-01-21 - US-019
- What was implemented: Editor toolbar with add/save/export/import buttons
- Files changed:
- src/components/editor/Toolbar.tsx - new toolbar component with styled buttons
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated toolbar with placeholder handlers
- **Learnings for future iterations:**
- Toolbar component accepts callback props for actions (onAddDialogue, onSave, etc.)
- Node type buttons use color coding: blue (Dialogue), green (Choice), orange (Variable)
- Action buttons (Save, Export, Import) use neutral bordered styling
- FlowchartEditor now uses flex-col layout to stack toolbar above canvas
- Placeholder handlers with TODO comments help track future implementation work
---
## 2026-01-21 - US-020
- What was implemented: Custom DialogueNode component for displaying/editing character dialogue
- Files changed:
- src/components/editor/nodes/DialogueNode.tsx - new custom node component with editable speaker and text fields
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered DialogueNode as custom node type
- **Learnings for future iterations:**
- Custom React Flow nodes use NodeProps<T> for typing, where T is the data shape
- Use useReactFlow() hook to get setNodes for updating node data from within the node component
- Handle components need Position enum (Position.Top, Position.Bottom) for positioning
- Custom handles can be styled with className and TailwindCSS, use ! prefix to override defaults (e.g., !h-3, !w-3)
- Node types must be registered in a nodeTypes object and passed to ReactFlow component
- Memoize nodeTypes with useMemo to prevent unnecessary re-renders
- Custom node components go in src/components/editor/nodes/ directory
---
## 2026-01-21 - US-021
- What was implemented: Add dialogue node from toolbar functionality
- Files changed:
- package.json - added nanoid dependency for unique ID generation
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddDialogue to create new dialogue nodes at viewport center
- **Learnings for future iterations:**
- useReactFlow() hook requires ReactFlowProvider wrapper, so split component into inner component and outer wrapper
- getViewport() returns { x, y, zoom } representing the current pan/zoom state
- Calculate viewport center: centerX = (-viewport.x + halfWidth) / viewport.zoom
- nanoid v5+ generates unique IDs synchronously with no dependencies
- Node creation pattern: create Node object with { id, type, position, data }, then add to state via setNodes
- React Flow nodes are draggable by default, no extra configuration needed
---
## 2026-01-21 - US-022
- What was implemented: Custom ChoiceNode component for displaying branching decisions
- Files changed:
- src/components/editor/nodes/ChoiceNode.tsx - new custom node component with green styling, editable prompt, and dynamic option handles
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered ChoiceNode as custom node type
- **Learnings for future iterations:**
- ChoiceNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
- Dynamic handles positioned using style={{ left: `${((index + 1) / (options.length + 1)) * 100}%` }} for even spacing
- Handle id format for options: 'option-0', 'option-1', etc. (matching the index)
- Each option needs a unique id (string) and label (string) per the ChoiceOption type
- updateOptionLabel callback pattern: find option by id, map over options array to update matching one
---
## 2026-01-21 - US-023
- What was implemented: Add choice node from toolbar functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddChoice to create new choice nodes at viewport center
- **Learnings for future iterations:**
- handleAddChoice follows same pattern as handleAddDialogue: get viewport center, create node with nanoid, add to state
- Choice nodes must be initialized with 2 options (each with unique id via nanoid and empty label)
- Node data structure for choice: { prompt: '', options: [{ id, label }, { id, label }] }
- React Flow nodes are draggable by default after being added to state
---
## 2026-01-21 - US-024
- What was implemented: Add/remove choice options functionality (2-6 options supported)
- Files changed:
- src/components/editor/nodes/ChoiceNode.tsx - added addOption and removeOption callbacks, '+' button to add options, 'x' button per option to remove
- **Learnings for future iterations:**
- Define MIN_OPTIONS and MAX_OPTIONS constants for clear limits
- Use disabled prop on buttons to enforce min/max constraints with appropriate styling (opacity-30, cursor-not-allowed)
- Remove button uses × character for simple cross icon
- Add button styled with border-dashed for visual distinction from action buttons
- Handles update dynamically via React Flow re-render when options array changes
---
## 2026-01-21 - US-025
- What was implemented: Custom VariableNode component for setting/modifying story variables
- Files changed:
- src/components/editor/nodes/VariableNode.tsx - new custom node component with orange styling, editable variable name, operation dropdown, and numeric value input
- src/app/editor/[projectId]/FlowchartEditor.tsx - imported and registered VariableNode in nodeTypes
- **Learnings for future iterations:**
- VariableNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
- Use parseFloat() with fallback to 0 for number input handling: `parseFloat(e.target.value) || 0`
- Operation dropdown uses select element with options for 'set', 'add', 'subtract'
- Type assertion needed for select value: `e.target.value as 'set' | 'add' | 'subtract'`
- Use `??` (nullish coalescing) for number defaults instead of `||` to allow 0 values: `data.value ?? 0`
---
## 2026-01-21 - US-026
- What was implemented: Add variable node from toolbar functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddVariable to create new variable nodes at viewport center
- **Learnings for future iterations:**
- handleAddVariable follows same pattern as handleAddDialogue and handleAddChoice: get viewport center, create node with nanoid, add to state
- Variable nodes initialized with { variableName: '', operation: 'set', value: 0 }
- All add node handlers share the same pattern and use the getViewportCenter helper
---
## 2026-01-21 - US-027
- What was implemented: Connect nodes with edges including arrow markers and smooth styling
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added MarkerType import, updated onConnect to create edges with smoothstep type and arrow markers, updated toReactFlowEdges to apply same styling to loaded edges
- **Learnings for future iterations:**
- Use `type: 'smoothstep'` for cleaner edge curves instead of default bezier
- Use `markerEnd: { type: MarkerType.ArrowClosed }` to add directional arrows to edges
- Connection type has nullable source/target, but Edge requires non-null strings - guard with early return
- Apply consistent edge styling in both onConnect (new edges) and toReactFlowEdges (loaded edges)
- Generate unique edge IDs with nanoid in onConnect callback
---

View File

@ -0,0 +1,199 @@
'use client'
import { useCallback, useMemo } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
useNodesState,
useEdgesState,
useReactFlow,
ReactFlowProvider,
addEdge,
Connection,
Node,
Edge,
NodeTypes,
MarkerType,
} from 'reactflow'
import { nanoid } from 'nanoid'
import 'reactflow/dist/style.css'
import Toolbar from '@/components/editor/Toolbar'
import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
type FlowchartEditorProps = {
projectId: string
initialData: FlowchartData
}
// Convert our FlowchartNode type to React Flow Node type
function toReactFlowNodes(nodes: FlowchartNode[]): Node[] {
return nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: node.data,
}))
}
// Convert our FlowchartEdge type to React Flow Edge type
function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
data: edge.data,
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
},
}))
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
dialogue: DialogueNode,
choice: ChoiceNode,
variable: VariableNode,
}),
[]
)
const { getViewport } = useReactFlow()
const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes)
)
const [edges, setEdges, onEdgesChange] = useEdgesState(
toReactFlowEdges(initialData.edges)
)
const onConnect = useCallback(
(params: Connection) => {
if (!params.source || !params.target) return
const newEdge: Edge = {
id: nanoid(),
source: params.source,
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
},
}
setEdges((eds) => addEdge(newEdge, eds))
},
[setEdges]
)
// Get center position of current viewport for placing new nodes
const getViewportCenter = useCallback(() => {
const viewport = getViewport()
// Calculate center based on viewport dimensions (assume ~800x600 visible area)
// Adjust based on zoom level
const centerX = (-viewport.x + 400) / viewport.zoom
const centerY = (-viewport.y + 300) / viewport.zoom
return { x: centerX, y: centerY }
}, [getViewport])
// Add dialogue node at viewport center
const handleAddDialogue = useCallback(() => {
const position = getViewportCenter()
const newNode: Node = {
id: nanoid(),
type: 'dialogue',
position,
data: { speaker: '', text: '' },
}
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleAddChoice = useCallback(() => {
const position = getViewportCenter()
const newNode: Node = {
id: nanoid(),
type: 'choice',
position,
data: {
prompt: '',
options: [
{ id: nanoid(), label: '' },
{ id: nanoid(), label: '' },
],
},
}
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleAddVariable = useCallback(() => {
const position = getViewportCenter()
const newNode: Node = {
id: nanoid(),
type: 'variable',
position,
data: {
variableName: '',
operation: 'set',
value: 0,
},
}
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleSave = useCallback(() => {
// TODO: Implement in US-034
}, [])
const handleExport = useCallback(() => {
// TODO: Implement in US-035
}, [])
const handleImport = useCallback(() => {
// TODO: Implement in US-036
}, [])
return (
<div className="flex h-full w-full flex-col">
<Toolbar
onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable}
onSave={handleSave}
onExport={handleExport}
onImport={handleImport}
/>
<div className="flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
</div>
</div>
)
}
// Outer wrapper component with ReactFlowProvider
export default function FlowchartEditor(props: FlowchartEditorProps) {
return (
<ReactFlowProvider>
<FlowchartEditorInner {...props} />
</ReactFlowProvider>
)
}

View File

@ -0,0 +1,40 @@
export default function EditorLoading() {
return (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<div className="h-5 w-5 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
<div className="h-6 w-32 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
</div>
</header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-3">
<svg
className="h-8 w-8 animate-spin text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Loading editor...
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import FlowchartEditor from './FlowchartEditor'
import type { FlowchartData } from '@/types/flowchart'
type PageProps = {
params: Promise<{ projectId: string }>
}
export default async function EditorPage({ params }: PageProps) {
const { projectId } = await params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return null
}
const { data: project, error } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (error || !project) {
notFound()
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
return (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
</Link>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{project.name}
</h1>
</div>
</header>
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
initialData={flowchartData}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,68 @@
'use client'
type ToolbarProps = {
onAddDialogue: () => void
onAddChoice: () => void
onAddVariable: () => void
onSave: () => void
onExport: () => void
onImport: () => void
}
export default function Toolbar({
onAddDialogue,
onAddChoice,
onAddVariable,
onSave,
onExport,
onImport,
}: ToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
<div className="flex items-center gap-2">
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
Add Node:
</span>
<button
onClick={onAddDialogue}
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Dialogue
</button>
<button
onClick={onAddChoice}
className="rounded bg-green-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Choice
</button>
<button
onClick={onAddVariable}
className="rounded bg-orange-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Variable
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={onSave}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Save
</button>
<button
onClick={onExport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Export
</button>
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Import
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,163 @@
'use client'
import { useCallback, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
type ChoiceOption = {
id: string
label: string
}
type ChoiceNodeData = {
prompt: string
options: ChoiceOption[]
}
const MIN_OPTIONS = 2
const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow()
const updatePrompt = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, prompt: e.target.value } }
: node
)
)
},
[id, setNodes]
)
const updateOptionLabel = useCallback(
(optionId: string, label: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, label } : opt
),
},
}
: node
)
)
},
[id, setNodes]
)
const addOption = useCallback(() => {
if (data.options.length >= MAX_OPTIONS) return
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: [
...node.data.options,
{ id: nanoid(), label: '' },
],
},
}
: node
)
)
}, [id, data.options.length, setNodes])
const removeOption = useCallback(
(optionId: string) => {
if (data.options.length <= MIN_OPTIONS) return
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.filter(
(opt: ChoiceOption) => opt.id !== optionId
),
},
}
: node
)
)
},
[id, data.options.length, setNodes]
)
return (
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
<Handle
type="target"
position={Position.Top}
id="input"
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
/>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
Choice
</div>
<input
type="text"
value={data.prompt || ''}
onChange={updatePrompt}
placeholder="What do you choose?"
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<div className="space-y-2">
{data.options.map((option, index) => (
<div key={option.id} className="relative flex items-center gap-1">
<input
type="text"
value={option.label}
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
placeholder={`Option ${index + 1}`}
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<button
type="button"
onClick={() => removeOption(option.id)}
disabled={data.options.length <= MIN_OPTIONS}
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
title="Remove option"
>
×
</button>
<Handle
type="source"
position={Position.Bottom}
id={`option-${index}`}
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
style={{
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
}}
/>
</div>
))}
</div>
<button
type="button"
onClick={addOption}
disabled={data.options.length >= MAX_OPTIONS}
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
title="Add option"
>
+ Add Option
</button>
</div>
)
}

View File

@ -0,0 +1,78 @@
'use client'
import { useCallback, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
type DialogueNodeData = {
speaker?: string
text: string
}
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow()
const updateNodeData = useCallback(
(field: keyof DialogueNodeData, value: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, [field]: value } }
: node
)
)
},
[id, setNodes]
)
const handleSpeakerChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
updateNodeData('speaker', e.target.value)
},
[updateNodeData]
)
const handleTextChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
updateNodeData('text', e.target.value)
},
[updateNodeData]
)
return (
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950">
<Handle
type="target"
position={Position.Top}
id="input"
className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white dark:!bg-zinc-800"
/>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Dialogue
</div>
<input
type="text"
value={data.speaker || ''}
onChange={handleSpeakerChange}
placeholder="Speaker"
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<textarea
value={data.text || ''}
onChange={handleTextChange}
placeholder="Dialogue text..."
rows={3}
className="w-full resize-none rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<Handle
type="source"
position={Position.Bottom}
id="output"
className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white dark:!bg-zinc-800"
/>
</div>
)
}

View File

@ -0,0 +1,103 @@
'use client'
import { useCallback, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
type VariableNodeData = {
variableName: string
operation: 'set' | 'add' | 'subtract'
value: number
}
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
const { setNodes } = useReactFlow()
const updateVariableName = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, variableName: e.target.value } }
: node
)
)
},
[id, setNodes]
)
const updateOperation = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
: node
)
)
},
[id, setNodes]
)
const updateValue = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value) || 0
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, value } }
: node
)
)
},
[id, setNodes]
)
return (
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950">
<Handle
type="target"
position={Position.Top}
id="input"
className="!h-3 !w-3 !border-2 !border-orange-500 !bg-white dark:!bg-zinc-800"
/>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-orange-700 dark:text-orange-300">
Variable
</div>
<input
type="text"
value={data.variableName || ''}
onChange={updateVariableName}
placeholder="variableName"
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<div className="mb-2 flex gap-2">
<select
value={data.operation || 'set'}
onChange={updateOperation}
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
>
<option value="set">set</option>
<option value="add">add</option>
<option value="subtract">subtract</option>
</select>
<input
type="number"
value={data.value ?? 0}
onChange={updateValue}
className="w-20 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
/>
</div>
<Handle
type="source"
position={Position.Bottom}
id="output"
className="!h-3 !w-3 !border-2 !border-orange-500 !bg-white dark:!bg-zinc-800"
/>
</div>
)
}