Compare commits
70
Commits
@@ -1,3 +1,7 @@
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
||||
|
||||
# Site URL (used for redirect URLs in emails)
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
||||
Generated
+576
-10
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 2,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -52,7 +52,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 3,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -67,7 +67,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 4,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -83,7 +83,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 5,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -102,7 +102,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 6,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -121,7 +121,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 7,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -136,7 +136,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 8,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -154,7 +154,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 9,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -173,7 +173,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 10,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -191,7 +191,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 11,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -210,7 +210,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 12,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -227,7 +227,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 13,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -244,7 +244,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 14,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -261,7 +261,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 15,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -280,7 +280,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 16,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -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": ""
|
||||
},
|
||||
{
|
||||
@@ -499,7 +499,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 28,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -514,7 +514,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 29,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -533,7 +533,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 30,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -552,7 +552,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 31,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -568,7 +568,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 32,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -585,7 +585,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 33,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -603,7 +603,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 34,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -620,7 +620,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 35,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -638,7 +638,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 36,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -658,7 +658,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 37,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -675,7 +675,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 38,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -692,8 +692,61 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 39,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-040",
|
||||
"title": "Conditionals on choice options",
|
||||
"description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).",
|
||||
"acceptanceCriteria": [
|
||||
"Each ChoiceOption can have optional condition (variableName, operator, value)",
|
||||
"Update ChoiceNode UI to show 'Add condition' button per option",
|
||||
"Condition editor modal for each option",
|
||||
"Visual indicator (icon/badge) on options with conditions",
|
||||
"Update TypeScript types: ChoiceOption gets optional condition field",
|
||||
"Export includes per-option conditions in Ren'Py JSON",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 40,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-018, US-019, US-025. Complexity: M"
|
||||
},
|
||||
{
|
||||
"id": "US-041",
|
||||
"title": "Change password for logged-in user",
|
||||
"description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.",
|
||||
"acceptanceCriteria": [
|
||||
"Settings/profile page accessible from dashboard header",
|
||||
"Form with: current password, new password, confirm new password fields",
|
||||
"Calls Supabase updateUser with new password",
|
||||
"Requires current password verification (re-authenticate)",
|
||||
"Shows success/error messages",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 41,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-004. Complexity: S"
|
||||
},
|
||||
{
|
||||
"id": "US-042",
|
||||
"title": "Password reset modal on token arrival",
|
||||
"description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.",
|
||||
"acceptanceCriteria": [
|
||||
"Detect password reset token in URL (from Supabase email link)",
|
||||
"Show modal/dialog automatically when token present",
|
||||
"Modal has: new password, confirm password fields",
|
||||
"Calls Supabase updateUser with token to complete reset",
|
||||
"On success, close modal and redirect to login",
|
||||
"On error, show error message",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 42,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-006. Complexity: S"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+606
@@ -3,6 +3,30 @@
|
||||
- Source files are in `src/` directory (app, components, lib, types)
|
||||
- Supabase is configured with @supabase/supabase-js and @supabase/ssr packages
|
||||
- Environment variables follow NEXT_PUBLIC_* convention for client-side access
|
||||
- Use `npm run typecheck` to run TypeScript type checking (tsc --noEmit)
|
||||
- Flowchart types exported from `src/types/flowchart.ts`
|
||||
- Supabase migrations go in `supabase/migrations/` with timestamp prefix (YYYYMMDDHHMMSS_*.sql)
|
||||
- Database has profiles table (linked to auth.users) and projects table (with flowchart_data JSONB)
|
||||
- RLS policies enforce user_id = auth.uid() for project access
|
||||
- Supabase client utilities in `src/lib/supabase/`: client.ts (browser), server.ts (App Router), middleware.ts (route protection)
|
||||
- Next.js middleware.ts at project root handles route protection using updateSession helper
|
||||
- Public auth routes: /login, /signup, /forgot-password, /reset-password
|
||||
- Protected routes: /dashboard, /editor/* (redirect to /login if unauthenticated)
|
||||
- Auth pages use 'use client' with useState, createClient() from lib/supabase/client.ts, and useRouter for redirects
|
||||
- For lists with client-side updates (delete/add), use wrapper client component that receives initialData from server component
|
||||
- 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')
|
||||
- Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message
|
||||
- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons
|
||||
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
|
||||
|
||||
---
|
||||
|
||||
@@ -26,3 +50,585 @@
|
||||
- npm package names can't have capital letters (use lowercase)
|
||||
- .gitignore needs explicit exclusion for .env files, but include .env.example
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-002
|
||||
- What was implemented: TypeScript types for flowchart data structures
|
||||
- Files changed:
|
||||
- src/types/flowchart.ts - new file with all flowchart type definitions
|
||||
- package.json - added typecheck script (tsc --noEmit)
|
||||
- **Learnings for future iterations:**
|
||||
- Position is a helper type for {x, y} coordinates used by nodes
|
||||
- FlowchartNode is a union type of DialogueNode | ChoiceNode | VariableNode
|
||||
- ChoiceOption is a separate type to make options array cleaner
|
||||
- All types use `export type` for TypeScript isolatedModules compatibility
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-003
|
||||
- What was implemented: Supabase schema for users and projects
|
||||
- Files changed:
|
||||
- supabase/migrations/20260121000000_create_profiles_and_projects.sql - new file with all database schema
|
||||
- **Learnings for future iterations:**
|
||||
- Supabase migrations are plain SQL files in supabase/migrations/ directory
|
||||
- Migration filenames use timestamp prefix (YYYYMMDDHHMMSS_description.sql)
|
||||
- RLS policies need separate policies for SELECT, INSERT, UPDATE, DELETE operations
|
||||
- Admin check policy uses EXISTS subquery to check is_admin flag on profiles table
|
||||
- projects table references profiles.id (not auth.users.id directly) for proper FK relationships
|
||||
- flowchart_data column uses JSONB type with default empty structure
|
||||
- Added auto-update trigger for updated_at timestamp on projects table
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-004
|
||||
- What was implemented: Supabase client configuration utilities
|
||||
- Files changed:
|
||||
- src/lib/supabase/client.ts - browser client using createBrowserClient from @supabase/ssr
|
||||
- src/lib/supabase/server.ts - server client for App Router with async cookies() API
|
||||
- src/lib/supabase/middleware.ts - middleware helper with updateSession function
|
||||
- src/lib/.gitkeep - removed (no longer needed)
|
||||
- **Learnings for future iterations:**
|
||||
- @supabase/ssr package provides createBrowserClient and createServerClient functions
|
||||
- Server client requires async cookies() from next/headers in Next.js 16
|
||||
- Middleware client returns both user object and supabaseResponse for route protection
|
||||
- Cookie handling uses getAll/setAll pattern for proper session management
|
||||
- setAll in server.ts wrapped in try/catch to handle Server Component limitations
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-005
|
||||
- What was implemented: Protected routes middleware for authentication
|
||||
- Files changed:
|
||||
- middleware.ts - new file at project root for route protection
|
||||
- **Learnings for future iterations:**
|
||||
- Next.js middleware.ts must be at project root (not in src/)
|
||||
- updateSession helper from lib/supabase/middleware.ts returns { user, supabaseResponse }
|
||||
- Use startsWith() for route matching to handle nested routes (e.g., /editor/*)
|
||||
- Matcher config excludes static files and images to avoid unnecessary middleware calls
|
||||
- Clone nextUrl before modifying pathname for redirects
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-006
|
||||
- What was implemented: Login page with email/password authentication
|
||||
- Files changed:
|
||||
- src/app/login/page.tsx - new file with login form and Supabase auth
|
||||
- **Learnings for future iterations:**
|
||||
- Auth pages use 'use client' directive since they need useState and form handling
|
||||
- Use createClient() from lib/supabase/client.ts for browser-side auth operations
|
||||
- supabase.auth.signInWithPassword returns { error } object for handling failures
|
||||
- useRouter from next/navigation for programmatic redirects after auth
|
||||
- Error state displayed in red alert box with dark mode support
|
||||
- Loading state disables submit button and shows "Signing in..." text
|
||||
- TailwindCSS dark mode uses dark: prefix (e.g., dark:bg-zinc-950)
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-007
|
||||
- What was implemented: Sign up page for invite-only account setup
|
||||
- Files changed:
|
||||
- src/app/signup/page.tsx - new file with signup form and Supabase auth
|
||||
- **Learnings for future iterations:**
|
||||
- Supabase invite tokens come via URL hash fragment (window.location.hash)
|
||||
- Parse hash with URLSearchParams after removing leading '#'
|
||||
- Check for type=invite or type=signup to detect invite flow
|
||||
- Use setSession() with access_token and refresh_token to establish session from invite link
|
||||
- For invited users, update password with updateUser() then create profile with upsert()
|
||||
- Use upsert() instead of insert() for profiles to handle edge cases
|
||||
- Validate password confirmation before submission (passwords match check)
|
||||
- display_name defaults to email prefix (split('@')[0])
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-008
|
||||
- What was implemented: Logout functionality component
|
||||
- Files changed:
|
||||
- src/components/LogoutButton.tsx - new client component with signOut and redirect
|
||||
- src/components/.gitkeep - removed (no longer needed)
|
||||
- **Learnings for future iterations:**
|
||||
- LogoutButton is a reusable component that will be used in the navbar (US-011)
|
||||
- Component uses 'use client' directive for client-side auth operations
|
||||
- Loading state prevents double-clicks during signOut
|
||||
- Styled with neutral zinc colors to work as a secondary button in navbars
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-009
|
||||
- What was implemented: Password reset - forgot password page
|
||||
- Files changed:
|
||||
- src/app/forgot-password/page.tsx - new file with forgot password form and email reset
|
||||
- **Learnings for future iterations:**
|
||||
- resetPasswordForEmail requires redirectTo option to specify where user lands after clicking reset link
|
||||
- Use `window.location.origin` to get the current site URL for redirectTo
|
||||
- Page shows different UI after success (conditional rendering with success state)
|
||||
- Use ' for apostrophe in JSX to avoid HTML entity issues
|
||||
- Follow same styling pattern as login page for consistency across auth pages
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-010
|
||||
- What was implemented: Password reset - set new password page
|
||||
- Files changed:
|
||||
- src/app/reset-password/page.tsx - new file with password reset form
|
||||
- src/app/login/page.tsx - updated to show success message from password reset
|
||||
- **Learnings for future iterations:**
|
||||
- Supabase recovery tokens come via URL hash fragment with type=recovery
|
||||
- Use setSession() with access_token and refresh_token from hash to establish recovery session
|
||||
- Show loading state while verifying token validity (tokenValid === null)
|
||||
- Show error state with link to request new reset if token is invalid
|
||||
- After password update, sign out the user and redirect to login with success message
|
||||
- Use query param (message=password_reset_success) to pass success state between pages
|
||||
- Login page uses useSearchParams to read and display success messages
|
||||
- Success messages styled with green background (bg-green-50)
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-011
|
||||
- What was implemented: Dashboard layout with navbar component
|
||||
- Files changed:
|
||||
- src/app/dashboard/layout.tsx - new file with dashboard layout wrapper
|
||||
- src/components/Navbar.tsx - new reusable navbar component
|
||||
- **Learnings for future iterations:**
|
||||
- Dashboard layout is a server component that fetches user data via createClient() from lib/supabase/server.ts
|
||||
- Navbar accepts userEmail prop to display current user
|
||||
- Layout wraps children with consistent max-w-7xl container and padding
|
||||
- Navbar uses Link component to allow clicking app title to go back to dashboard
|
||||
- Navbar has border-b styling with dark mode support for visual separation
|
||||
- Use gap-4 for spacing between navbar items (user email and logout button)
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-012
|
||||
- What was implemented: Dashboard page listing user projects
|
||||
- Files changed:
|
||||
- src/app/dashboard/page.tsx - new file with project listing, cards, and empty state
|
||||
- **Learnings for future iterations:**
|
||||
- Dashboard page is a server component that fetches projects directly from Supabase
|
||||
- Use .eq('user_id', user.id) for RLS-backed queries (though RLS also enforces this)
|
||||
- Order by updated_at descending to show most recent projects first
|
||||
- formatDate() helper with toLocaleDateString for human-readable dates
|
||||
- Project cards use Link component for navigation to /editor/[projectId]
|
||||
- Empty state uses dashed border (border-dashed) with centered content and icon
|
||||
- Hover effects on cards: border-blue-300, shadow-md, and text color change on title
|
||||
- Error state displayed if Supabase query fails
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-013
|
||||
- What was implemented: Create new project functionality
|
||||
- Files changed:
|
||||
- src/components/NewProjectButton.tsx - new client component with modal dialog
|
||||
- src/app/dashboard/page.tsx - added NewProjectButton to header area
|
||||
- src/app/signup/page.tsx - fixed lint error (setState in effect) by initializing email from searchParams
|
||||
- **Learnings for future iterations:**
|
||||
- Modal dialogs use fixed positioning with backdrop (bg-black/50) for overlay effect
|
||||
- Form submission uses Supabase insert with .select('id').single() to get the new record ID
|
||||
- Initialize flowchart_data with { nodes: [], edges: [] } for new projects
|
||||
- router.push() for programmatic navigation after successful creation
|
||||
- autoFocus on input for better UX when modal opens
|
||||
- Prevent modal close while loading (check isLoading before calling handleClose)
|
||||
- ESLint rule react-hooks/set-state-in-effect warns against synchronous setState in useEffect
|
||||
- Initialize state from searchParams directly in useState() instead of setting in useEffect
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-014
|
||||
- What was implemented: Delete project functionality with confirmation dialog and toast
|
||||
- Files changed:
|
||||
- src/components/ProjectCard.tsx - new client component replacing Link, with delete button and confirmation dialog
|
||||
- src/components/ProjectList.tsx - new wrapper component to manage project list state and toast notifications
|
||||
- src/components/Toast.tsx - new reusable toast notification component
|
||||
- src/app/dashboard/page.tsx - updated to use ProjectList instead of inline rendering
|
||||
- **Learnings for future iterations:**
|
||||
- To enable client-side state updates (like removing items), extract list rendering from server components into client components
|
||||
- ProjectList accepts initialProjects from server and manages state locally for immediate UI updates
|
||||
- Use onDelete callback pattern to propagate deletion events from child (ProjectCard) to parent (ProjectList)
|
||||
- Delete button uses e.stopPropagation() to prevent card click navigation when clicking delete
|
||||
- Confirmation dialogs should disable close/cancel while action is in progress (isDeleting check)
|
||||
- Toast component uses useCallback for handlers and auto-dismiss with setTimeout
|
||||
- Toast animations can use TailwindCSS animate-in utilities (fade-in, slide-in-from-bottom-4)
|
||||
- Delete icon appears on hover using group-hover:opacity-100 with parent group class
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-015
|
||||
- What was implemented: Rename project functionality
|
||||
- Files changed:
|
||||
- src/components/ProjectCard.tsx - added rename button, modal dialog, and Supabase update logic
|
||||
- src/components/ProjectList.tsx - added handleRename callback and toast notification
|
||||
- **Learnings for future iterations:**
|
||||
- Multiple action buttons on a card can be grouped in a flex container with gap-1
|
||||
- Rename modal follows same pattern as delete dialog: fixed positioning, backdrop, form
|
||||
- Use onKeyDown to handle Enter key for quick form submission
|
||||
- Reset form state (newName, error) when opening modal to handle edge cases
|
||||
- Check if name is unchanged before making API call to avoid unnecessary requests
|
||||
- Trim whitespace from input value before validation and submission
|
||||
- handleRename callback updates project name in state using map() to preserve list order
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-016
|
||||
- What was implemented: Admin invite user functionality
|
||||
- Files changed:
|
||||
- src/app/admin/invite/page.tsx - new admin-only page with access check (redirects non-admins)
|
||||
- src/app/admin/invite/InviteForm.tsx - client component with invite form and state management
|
||||
- src/app/admin/invite/actions.ts - server action using service role key to call inviteUserByEmail
|
||||
- src/components/Navbar.tsx - added isAdmin prop and "Invite User" link (visible only to admins)
|
||||
- src/app/dashboard/layout.tsx - fetches profile.is_admin and passes it to Navbar
|
||||
- .env.example - added SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SITE_URL
|
||||
- **Learnings for future iterations:**
|
||||
- Admin operations require SUPABASE_SERVICE_ROLE_KEY (server-side only, not NEXT_PUBLIC_*)
|
||||
- Use createClient from @supabase/supabase-js directly for admin client (not @supabase/ssr)
|
||||
- Admin client needs auth config: { autoRefreshToken: false, persistSession: false }
|
||||
- inviteUserByEmail requires redirectTo option for the signup link in email
|
||||
- Server actions ('use server') can access private env vars safely
|
||||
- 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
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-028
|
||||
- What was implemented: Select and delete nodes functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added deleteKeyCode prop to enable Delete/Backspace key deletion
|
||||
- **Learnings for future iterations:**
|
||||
- React Flow has built-in node selection via clicking - no extra configuration needed
|
||||
- Use `deleteKeyCode={['Delete', 'Backspace']}` prop to enable keyboard deletion
|
||||
- React Flow automatically removes connected edges when a node is deleted (no manual cleanup needed)
|
||||
- The useNodesState/useEdgesState hooks with onNodesChange/onEdgesChange handle all deletion state updates
|
||||
- No explicit onNodesDelete callback is needed - the onNodesChange handler covers deletion events
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-029
|
||||
- What was implemented: Select and delete edges functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added onEdgesDelete callback
|
||||
- **Learnings for future iterations:**
|
||||
- React Flow 11 edges are clickable and selectable by default (interactionWidth renders invisible interaction area)
|
||||
- The `deleteKeyCode` prop works for both nodes and edges - same configuration covers both
|
||||
- onEdgesDelete is optional if you just need state management (onEdgesChange handles removal events)
|
||||
- onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo
|
||||
- Edge selection shows visual highlight via React Flow's built-in styling
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-030
|
||||
- What was implemented: Right-click context menu for canvas, nodes, and edges
|
||||
- Files changed:
|
||||
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
|
||||
- **Learnings for future iterations:**
|
||||
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
|
||||
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
|
||||
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
|
||||
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
|
||||
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
|
||||
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-031
|
||||
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
|
||||
- Files changed:
|
||||
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
|
||||
- **Learnings for future iterations:**
|
||||
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
|
||||
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
|
||||
- Use `edge.data.condition` to access condition object on edges
|
||||
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
|
||||
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
|
||||
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-032
|
||||
- What was implemented: Display conditions on edges with dashed styling and labels
|
||||
- Files changed:
|
||||
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
|
||||
- **Learnings for future iterations:**
|
||||
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
|
||||
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
|
||||
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
|
||||
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
|
||||
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
|
||||
- Custom edges go in `src/components/editor/edges/` directory
|
||||
- Use amber color scheme for conditional edges to distinguish from regular edges
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-033
|
||||
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
|
||||
- **Learnings for future iterations:**
|
||||
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
|
||||
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
|
||||
- Debounce saves with 1 second delay using useRef for timer tracking
|
||||
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
|
||||
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
|
||||
- Check `typeof window === 'undefined'` in lazy initializer for SSR safety
|
||||
- clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save
|
||||
- JSON.stringify comparison works for flowchart data equality check
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-034
|
||||
- What was implemented: Save project to database functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
|
||||
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
|
||||
- **Learnings for future iterations:**
|
||||
- Use createClient() from lib/supabase/client.ts for browser-side database operations
|
||||
- Supabase update returns { error } object for error handling
|
||||
- Use async/await with try/catch for async save operations
|
||||
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
|
||||
- Clear LocalStorage draft after successful save to avoid stale drafts
|
||||
- Toast state uses object with message and type for flexibility
|
||||
- Loading spinner SVG with animate-spin class for visual feedback during save
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-035
|
||||
- What was implemented: Export project as .vnflow file functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
|
||||
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
|
||||
- **Learnings for future iterations:**
|
||||
- Use Blob with type 'application/json' for JSON file downloads
|
||||
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
|
||||
- URL.createObjectURL creates a temporary URL for the blob
|
||||
- Create temporary anchor element with download attribute to trigger file download
|
||||
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
|
||||
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-036
|
||||
- What was implemented: Import project from .vnflow file functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
|
||||
- **Learnings for future iterations:**
|
||||
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
|
||||
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
|
||||
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
|
||||
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
|
||||
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
|
||||
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
|
||||
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-037
|
||||
- What was implemented: Export to Ren'Py JSON format functionality
|
||||
- Files changed:
|
||||
- src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback
|
||||
- **Learnings for future iterations:**
|
||||
- Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode
|
||||
- Find first node by identifying nodes with no incoming edges (not in any edge's target set)
|
||||
- Use graph traversal (DFS) to organize nodes into labeled sections based on flow
|
||||
- Choice nodes create branching sections - save current section before processing each branch
|
||||
- Track visited nodes to detect cycles and create proper labels for jump references
|
||||
- Labels are generated based on speaker name or incremental counter for uniqueness
|
||||
- Replace node IDs with proper labels in a second pass after traversal completes
|
||||
- Include metadata (projectName, exportedAt) at the top level of the export
|
||||
- Validate JSON output with JSON.parse before download to ensure validity
|
||||
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-038
|
||||
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
|
||||
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
|
||||
- **Learnings for future iterations:**
|
||||
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
|
||||
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
|
||||
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
|
||||
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
|
||||
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
|
||||
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
|
||||
- "(unsaved changes)" indicator shown next to project name when isDirty is true
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-039
|
||||
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
|
||||
- Files changed:
|
||||
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
|
||||
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
|
||||
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
|
||||
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
|
||||
- **Learnings for future iterations:**
|
||||
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
|
||||
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
|
||||
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
|
||||
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
|
||||
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
|
||||
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-040
|
||||
- What was implemented: Conditionals on choice options - per-option visibility conditions
|
||||
- Files changed:
|
||||
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
|
||||
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
|
||||
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
|
||||
- **Learnings for future iterations:**
|
||||
- Per-option conditions use the same Condition type as edge conditions
|
||||
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
|
||||
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
|
||||
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
|
||||
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
|
||||
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
|
||||
- Condition badge below option shows "if variableName operator value" text in compact format
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-041
|
||||
- What was implemented: Change password for logged-in user from settings page
|
||||
- Files changed:
|
||||
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
|
||||
- src/components/Navbar.tsx - added "Settings" link to navbar
|
||||
- **Learnings for future iterations:**
|
||||
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
|
||||
- Re-authentication uses signInWithPassword with current password before allowing updateUser
|
||||
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
|
||||
- Password validation: check match and minimum length (6 chars) before making API calls
|
||||
- Clear form fields after successful password update for security
|
||||
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-042
|
||||
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
|
||||
- Files changed:
|
||||
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
|
||||
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
|
||||
- **Learnings for future iterations:**
|
||||
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
|
||||
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
|
||||
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
|
||||
- Modal uses fixed positioning with z-50 to overlay above page content
|
||||
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
|
||||
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
|
||||
---
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { inviteUser } from './actions'
|
||||
|
||||
export default function InviteForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
setLoading(true)
|
||||
|
||||
const result = await inviteUser(email)
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to send invitation')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setEmail('')
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
Invitation sent successfully! The user will receive an email with instructions to complete their account setup.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-sm font-medium text-zinc-600 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Invitation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createClient as createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function inviteUser(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
// First, verify the current user is an admin
|
||||
const supabase = await createServerClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'You must be logged in to invite users' }
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (profileError || !profile?.is_admin) {
|
||||
return { success: false, error: 'You do not have permission to invite users' }
|
||||
}
|
||||
|
||||
// Create admin client with service role key for inviting users
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!serviceRoleKey) {
|
||||
return { success: false, error: 'Server configuration error: missing service role key' }
|
||||
}
|
||||
|
||||
const adminClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
serviceRoleKey,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Invite the user
|
||||
const { error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/signup`,
|
||||
})
|
||||
|
||||
if (inviteError) {
|
||||
return { success: false, error: inviteError.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import InviteForm from './InviteForm'
|
||||
|
||||
export default async function AdminInvitePage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (!profile?.is_admin) {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
|
||||
Invite New User
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Send an invitation email to a new user to give them access to WebVNWrite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InviteForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import Navbar from '@/components/Navbar'
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Fetch user profile to check admin status
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = profile?.is_admin || false
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar userEmail={user.email || ''} isAdmin={isAdmin} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import NewProjectButton from '@/components/NewProjectButton'
|
||||
import ProjectList from '@/components/ProjectList'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: projects, error } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, updated_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-red-700 dark:text-red-400">
|
||||
Failed to load projects. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
|
||||
Your Projects
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Select a project to open the flowchart editor
|
||||
</p>
|
||||
</div>
|
||||
<NewProjectButton />
|
||||
</div>
|
||||
|
||||
<ProjectList initialProjects={projects || []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const supabase = createClient()
|
||||
|
||||
// Re-authenticate with current password
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user?.email) {
|
||||
setError('Unable to verify current user.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: user.email,
|
||||
password: currentPassword,
|
||||
})
|
||||
|
||||
if (signInError) {
|
||||
setError('Current password is incorrect.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Update to new password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess('Password updated successfully.')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch {
|
||||
setError('An unexpected error occurred.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-8">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-4">
|
||||
Change Password
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="currentPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
|
||||
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">
|
||||
<LoadingSpinner size="lg" message="Loading editor..." />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
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) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
aria-label="Back to dashboard"
|
||||
>
|
||||
<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>
|
||||
</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-4 text-center">
|
||||
<svg
|
||||
className="h-12 w-12 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Project Not Found
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
|
||||
The project you're looking for doesn't exist or you don't have access to it.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const flowchartData = (project.flowchart_data || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}) as FlowchartData
|
||||
|
||||
return (
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
initialData={flowchartData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/reset-password`,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
We've sent a password reset link to <strong>{email}</strong>.
|
||||
Please check your inbox and follow the instructions to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Check for success message from password reset
|
||||
const message = searchParams.get('message')
|
||||
const successMessage = message === 'password_reset_success'
|
||||
? 'Your password has been reset successfully. Please sign in with your new password.'
|
||||
: null
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{successMessage && (
|
||||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
-103
@@ -1,117 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Suspense } from 'react'
|
||||
import LoginForm from './LoginForm'
|
||||
import PasswordResetModal from '@/components/PasswordResetModal'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to your account
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
}
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
<PasswordResetModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-62
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tokenValid, setTokenValid] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle recovery token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect from reset email
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && type === 'recovery') {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired reset link. Please request a new password reset.')
|
||||
setTokenValid(false)
|
||||
return
|
||||
}
|
||||
|
||||
setTokenValid(true)
|
||||
} else {
|
||||
setError('Invalid or expired reset link. Please request a new password reset.')
|
||||
setTokenValid(false)
|
||||
}
|
||||
} else {
|
||||
// No hash in URL - check if user already has a session from a previous recovery
|
||||
const supabase = createClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
setTokenValid(true)
|
||||
} else {
|
||||
setError('Invalid or expired reset link. Please request a new password reset.')
|
||||
setTokenValid(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Sign out after password reset so user can sign in with new password
|
||||
await supabase.auth.signOut()
|
||||
|
||||
// Redirect to login with success message
|
||||
router.push('/login?message=password_reset_success')
|
||||
}
|
||||
|
||||
// Show loading state while checking token
|
||||
if (tokenValid === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">Verifying reset link...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error state if token is invalid
|
||||
if (tokenValid === false) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Reset link expired
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Request a new reset link
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Set new password
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function SignupForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Pre-fill email if provided in URL (from invite link)
|
||||
const [email, setEmail] = useState(searchParams.get('email') ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle invite/signup token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired invite link. Please request a new invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user?.email) {
|
||||
setEmail(user.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
// Check if user already has a session (from invite link)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
// User was invited and has a session - update their password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
display_name: session.user.email?.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
// Regular signup flow (if allowed)
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
display_name: email.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Complete your account setup
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from 'react'
|
||||
import SignupForm from './SignupForm'
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignupForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
type LoadingSpinnerProps = {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<svg
|
||||
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
|
||||
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>
|
||||
{message && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LogoutButton() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleLogout() {
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loading}
|
||||
className="rounded-md bg-zinc-200 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
{loading ? 'Signing out...' : 'Sign out'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link'
|
||||
import LogoutButton from './LogoutButton'
|
||||
|
||||
interface NavbarProps {
|
||||
userEmail: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
||||
return (
|
||||
<nav className="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-xl font-bold text-zinc-900 dark:text-zinc-50"
|
||||
>
|
||||
WebVNWrite
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin/invite"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Invite User
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{userEmail}
|
||||
</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function NewProjectButton() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true)
|
||||
setProjectName('')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
setIsOpen(false)
|
||||
setProjectName('')
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!projectName.trim()) {
|
||||
setError('Project name is required')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
setError('You must be logged in to create a project')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { data, error: insertError } = await supabase
|
||||
.from('projects')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: projectName.trim(),
|
||||
flowchart_data: { nodes: [], edges: [] },
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
setError(insertError.message || 'Failed to create project')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
router.push(`/editor/${data.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Project
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Create New Project
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter a name for your new visual novel project.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4">
|
||||
<label
|
||||
htmlFor="projectName"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projectName"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="My Visual Novel"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function PasswordResetModal() {
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tokenError, setTokenError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && type === 'recovery') {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setTokenError('Invalid or expired reset link. Please request a new password reset.')
|
||||
return
|
||||
}
|
||||
|
||||
// Clear hash from URL without reloading
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await supabase.auth.signOut()
|
||||
|
||||
setIsOpen(false)
|
||||
router.push('/login?message=password_reset_success')
|
||||
}
|
||||
|
||||
if (tokenError) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Reset link expired
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{tokenError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setTokenError(null)}
|
||||
className="mt-4 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reset-password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reset-confirm-password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
interface ProjectCardProps {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
onDelete: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function ProjectCard({
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: ProjectCardProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newName, setNewName] = useState(name)
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(`/editor/${id}`)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.from('projects').delete().eq('id', id)
|
||||
|
||||
if (error) {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
alert('Failed to delete project: ' + error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
onDelete(id)
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setNewName(name)
|
||||
setRenameError(null)
|
||||
setShowRenameDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmRename = async () => {
|
||||
if (!newName.trim()) {
|
||||
setRenameError('Project name cannot be empty')
|
||||
return
|
||||
}
|
||||
|
||||
if (newName.trim() === name) {
|
||||
setShowRenameDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
setRenameError(null)
|
||||
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.update({ name: newName.trim() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
setIsRenaming(false)
|
||||
setRenameError('Failed to rename project: ' + error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(false)
|
||||
setShowRenameDialog(false)
|
||||
onRename(id, newName.trim())
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
if (!isRenaming) {
|
||||
setShowRenameDialog(false)
|
||||
setRenameError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
|
||||
>
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleRenameClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
|
||||
title="Rename project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
title="Delete project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400">
|
||||
{name}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Last updated: {formatDate(updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showDeleteDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleCancelDelete}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Delete Project
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Are you sure you want to delete "{name}"? This action
|
||||
cannot be undone and all flowchart data will be permanently
|
||||
removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelDelete}
|
||||
disabled={isDeleting}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRenameDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleCancelRename}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Rename Project
|
||||
</h3>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="project-name"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isRenaming) {
|
||||
handleConfirmRename()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
disabled={isRenaming}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{renameError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelRename}
|
||||
disabled={isRenaming}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isRenaming ? 'Renaming...' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import ProjectCard from './ProjectCard'
|
||||
import Toast from './Toast'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ProjectListProps {
|
||||
initialProjects: Project[]
|
||||
}
|
||||
|
||||
export default function ProjectList({ initialProjects }: ProjectListProps) {
|
||||
const [projects, setProjects] = useState<Project[]>(initialProjects)
|
||||
const [toast, setToast] = useState<{
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
} | null>(null)
|
||||
|
||||
const handleDelete = useCallback((deletedId: string) => {
|
||||
setProjects((prev) => prev.filter((p) => p.id !== deletedId))
|
||||
setToast({ message: 'Project deleted successfully', type: 'success' })
|
||||
}, [])
|
||||
|
||||
const handleRename = useCallback((id: string, newName: string) => {
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, name: newName } : p))
|
||||
)
|
||||
setToast({ message: 'Project renamed successfully', type: 'success' })
|
||||
}, [])
|
||||
|
||||
const handleCloseToast = useCallback(() => {
|
||||
setToast(null)
|
||||
}, [])
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-dashed border-zinc-300 bg-zinc-50 p-12 text-center dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
No projects yet
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Get started by creating your first visual novel project.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
updatedAt={project.updated_at}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleCloseToast}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
onClose: () => void
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, onClose, action }: ToastProps) {
|
||||
useEffect(() => {
|
||||
// Don't auto-dismiss if there's an action button
|
||||
if (action) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose, action])
|
||||
|
||||
const bgColor =
|
||||
type === 'success'
|
||||
? 'bg-green-600 dark:bg-green-700'
|
||||
: 'bg-red-600 dark:bg-red-700'
|
||||
|
||||
const icon =
|
||||
type === 'success' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium text-white shadow-lg ${bgColor}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{message}</span>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="ml-2 rounded bg-white/20 px-2 py-0.5 text-xs font-semibold hover:bg-white/30"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ConditionEditorProps = {
|
||||
edgeId: string
|
||||
condition?: Condition
|
||||
onSave: (edgeId: string, condition: Condition) => void
|
||||
onRemove: (edgeId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
|
||||
|
||||
export default function ConditionEditor({
|
||||
edgeId,
|
||||
condition,
|
||||
onSave,
|
||||
onRemove,
|
||||
onCancel,
|
||||
}: ConditionEditorProps) {
|
||||
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
|
||||
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
|
||||
const [value, setValue] = useState(condition?.value ?? 0)
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!variableName.trim()) return
|
||||
onSave(edgeId, {
|
||||
variableName: variableName.trim(),
|
||||
operator,
|
||||
value,
|
||||
})
|
||||
}, [edgeId, variableName, operator, value, onSave])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(edgeId)
|
||||
}, [edgeId, onRemove])
|
||||
|
||||
const hasExistingCondition = !!condition
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{hasExistingCondition ? 'Edit Condition' : 'Add Condition'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Variable Name Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="variableName"
|
||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Variable Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="variableName"
|
||||
value={variableName}
|
||||
onChange={(e) => setVariableName(e.target.value)}
|
||||
placeholder="e.g., score, health, affection"
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operator Dropdown */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="operator"
|
||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
id="operator"
|
||||
value={operator}
|
||||
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Value Number Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="value"
|
||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{variableName.trim() && (
|
||||
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
Condition: <code className="font-mono text-blue-600 dark:text-blue-400">{variableName.trim()} {operator} {value}</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<div>
|
||||
{hasExistingCondition && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!variableName.trim()}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
export type ContextMenuType = 'canvas' | 'node' | 'edge'
|
||||
|
||||
type ContextMenuProps = {
|
||||
x: number
|
||||
y: number
|
||||
type: ContextMenuType
|
||||
onClose: () => void
|
||||
onAddDialogue?: () => void
|
||||
onAddChoice?: () => void
|
||||
onAddVariable?: () => void
|
||||
onDelete?: () => void
|
||||
onAddCondition?: () => void
|
||||
}
|
||||
|
||||
export default function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
type,
|
||||
onClose,
|
||||
onAddDialogue,
|
||||
onAddChoice,
|
||||
onAddVariable,
|
||||
onDelete,
|
||||
onAddCondition,
|
||||
}: ContextMenuProps) {
|
||||
// Close menu on Escape key
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
// Close menu on click outside
|
||||
const handleClickOutside = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [handleKeyDown, handleClickOutside])
|
||||
|
||||
const menuItemClass =
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{type === 'canvas' && (
|
||||
<>
|
||||
<button
|
||||
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
|
||||
onClick={() => {
|
||||
onAddDialogue?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Dialogue
|
||||
</button>
|
||||
<button
|
||||
className={`${menuItemClass} text-green-600 dark:text-green-400`}
|
||||
onClick={() => {
|
||||
onAddChoice?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Choice
|
||||
</button>
|
||||
<button
|
||||
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
|
||||
onClick={() => {
|
||||
onAddVariable?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'node' && (
|
||||
<button
|
||||
className={`${menuItemClass} text-red-600 dark:text-red-400`}
|
||||
onClick={() => {
|
||||
onDelete?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type === 'edge' && (
|
||||
<>
|
||||
<button
|
||||
className={`${menuItemClass} text-red-600 dark:text-red-400`}
|
||||
onClick={() => {
|
||||
onDelete?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onAddCondition?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Condition
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type OptionConditionEditorProps = {
|
||||
optionId: string
|
||||
optionLabel: string
|
||||
condition?: Condition
|
||||
onSave: (optionId: string, condition: Condition) => void
|
||||
onRemove: (optionId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
|
||||
|
||||
export default function OptionConditionEditor({
|
||||
optionId,
|
||||
optionLabel,
|
||||
condition,
|
||||
onSave,
|
||||
onRemove,
|
||||
onCancel,
|
||||
}: OptionConditionEditorProps) {
|
||||
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
|
||||
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
|
||||
const [value, setValue] = useState(condition?.value ?? 0)
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!variableName.trim()) return
|
||||
onSave(optionId, {
|
||||
variableName: variableName.trim(),
|
||||
operator,
|
||||
value,
|
||||
})
|
||||
}, [optionId, variableName, operator, value, onSave])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(optionId)
|
||||
}, [optionId, onRemove])
|
||||
|
||||
const hasExistingCondition = !!condition
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Option: {optionLabel || '(unnamed)'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Variable Name Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="optionVariableName"
|
||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Variable Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="optionVariableName"
|
||||
value={variableName}
|
||||
onChange={(e) => setVariableName(e.target.value)}
|
||||
placeholder="e.g., affection, score, health"
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operator Dropdown */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="optionOperator"
|
||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
id="optionOperator"
|
||||
value={operator}
|
||||
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Value Number Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="optionValue"
|
||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="optionValue"
|
||||
value={value}
|
||||
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{variableName.trim() && (
|
||||
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
Show option when: <code className="font-mono text-amber-600 dark:text-amber-400">{variableName.trim()} {operator} {value}</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<div>
|
||||
{hasExistingCondition && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!variableName.trim()}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
type ToolbarProps = {
|
||||
onAddDialogue: () => void
|
||||
onAddChoice: () => void
|
||||
onAddVariable: () => void
|
||||
onSave: () => void
|
||||
onExport: () => void
|
||||
onExportRenpy: () => void
|
||||
onImport: () => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
onAddDialogue,
|
||||
onAddChoice,
|
||||
onAddVariable,
|
||||
onSave,
|
||||
onExport,
|
||||
onExportRenpy,
|
||||
onImport,
|
||||
isSaving = false,
|
||||
}: 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}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 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 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
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>
|
||||
)}
|
||||
{isSaving ? 'Saving...' : '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={onExportRenpy}
|
||||
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Export to Ren'Py
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
getSmoothStepPath,
|
||||
} from 'reactflow'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ConditionalEdgeData = {
|
||||
condition?: Condition
|
||||
}
|
||||
|
||||
export default function ConditionalEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
markerEnd,
|
||||
selected,
|
||||
}: EdgeProps<ConditionalEdgeData>) {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const hasCondition = !!data?.condition
|
||||
|
||||
// Format condition as readable label
|
||||
const conditionLabel = hasCondition
|
||||
? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
strokeDasharray: hasCondition ? '5 5' : undefined,
|
||||
stroke: selected ? '#3b82f6' : hasCondition ? '#f59e0b' : '#64748b',
|
||||
strokeWidth: selected ? 2 : 1.5,
|
||||
}}
|
||||
/>
|
||||
{conditionLabel && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="rounded border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 dark:border-amber-600 dark:bg-amber-900 dark:text-amber-200"
|
||||
>
|
||||
{conditionLabel}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||
|
||||
type ChoiceOption = {
|
||||
id: string
|
||||
label: string
|
||||
condition?: Condition
|
||||
}
|
||||
|
||||
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 [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
const handleSaveCondition = useCallback(
|
||||
(optionId: string, condition: Condition) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
setEditingConditionOptionId(null)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(
|
||||
(optionId: string) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) => {
|
||||
if (opt.id !== optionId) return opt
|
||||
const updated = { ...opt }
|
||||
delete updated.condition
|
||||
return updated
|
||||
}),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
setEditingConditionOptionId(null)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const editingOption = editingConditionOptionId
|
||||
? data.options.find((opt) => opt.id === editingConditionOptionId)
|
||||
: null
|
||||
|
||||
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}>
|
||||
<div 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={() => setEditingConditionOptionId(option.id)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||
option.condition
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
||||
>
|
||||
{option.condition ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
{option.condition && (
|
||||
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
||||
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{editingOption && (
|
||||
<OptionConditionEditor
|
||||
optionId={editingOption.id}
|
||||
optionLabel={editingOption.label}
|
||||
condition={editingOption.condition}
|
||||
onSave={handleSaveCondition}
|
||||
onRemove={handleRemoveCondition}
|
||||
onCancel={() => setEditingConditionOptionId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,13 @@ export type Position = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
// Condition type for conditional edges and choice options
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number;
|
||||
};
|
||||
|
||||
// DialogueNode type: represents character speech/dialogue
|
||||
export type DialogueNode = {
|
||||
id: string;
|
||||
@@ -19,6 +26,7 @@ export type DialogueNode = {
|
||||
export type ChoiceOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
condition?: Condition;
|
||||
};
|
||||
|
||||
// ChoiceNode type: represents branching decisions
|
||||
@@ -47,13 +55,6 @@ export type VariableNode = {
|
||||
// Union type for all node types
|
||||
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
|
||||
|
||||
// Condition type for conditional edges
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number;
|
||||
};
|
||||
|
||||
// FlowchartEdge type: represents connections between nodes
|
||||
export type FlowchartEdge = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user