Add canvas workflow and harden data import
This commit is contained in:
@@ -76,7 +76,9 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.5",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"ag-psd": "^30.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-dev-inspector/babel-plugin": "^2.0.1",
|
||||
|
||||
166
pnpm-lock.yaml
generated
166
pnpm-lock.yaml
generated
@@ -105,6 +105,12 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: 2.95.3
|
||||
version: 2.95.3
|
||||
'@xyflow/react':
|
||||
specifier: ^12.10.2
|
||||
version: 12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
ag-psd:
|
||||
specifier: ^30.1.1
|
||||
version: 30.1.1
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -1403,24 +1409,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.4':
|
||||
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.4':
|
||||
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.4':
|
||||
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.4':
|
||||
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
||||
@@ -2638,6 +2648,9 @@ packages:
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
@@ -2650,6 +2663,9 @@ packages:
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-selection@3.0.11':
|
||||
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
@@ -2659,6 +2675,12 @@ packages:
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -2833,41 +2855,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -2940,6 +2970,15 @@ packages:
|
||||
'@xtuc/long@4.2.2':
|
||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||
|
||||
'@xyflow/react@12.10.2':
|
||||
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2969,6 +3008,9 @@ packages:
|
||||
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
ag-psd@30.1.1:
|
||||
resolution: {integrity: sha512-0GbWYR4Rvm1QnWCYeMiVbUJBXnSyTUKvNUK2tIIVDt/wrUVUL9pHTsnwqOTonEC2RRh5I/aUcGydc1LNgXfJWA==}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3252,6 +3294,9 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3388,6 +3433,14 @@ packages:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3408,6 +3461,10 @@ packages:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3424,6 +3481,16 @@ packages:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
damerau-levenshtein@1.0.8:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
@@ -5245,6 +5312,9 @@ packages:
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6368,6 +6438,21 @@ packages:
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
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
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -9034,6 +9119,10 @@ snapshots:
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
@@ -9046,6 +9135,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-selection@3.0.11': {}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
@@ -9054,6 +9145,15 @@ snapshots:
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -9362,6 +9462,29 @@ snapshots:
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
||||
'@xyflow/react@12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.76
|
||||
classcat: 5.0.5
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
zustand: 4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -9381,6 +9504,11 @@ snapshots:
|
||||
|
||||
address@1.2.2: {}
|
||||
|
||||
ag-psd@30.1.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
pako: 2.1.0
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.18.0):
|
||||
@@ -9686,6 +9814,8 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
@@ -9812,6 +9942,13 @@ snapshots:
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
@@ -9830,6 +9967,8 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
@@ -9844,6 +9983,23 @@ snapshots:
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
damerau-levenshtein@1.0.8: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
@@ -11947,6 +12103,8 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -13333,4 +13491,12 @@ snapshots:
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.10
|
||||
immer: 9.0.21
|
||||
react: 19.2.3
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
BIN
public/canvas/workflows/scene01.jpeg
Executable file
BIN
public/canvas/workflows/scene01.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 898 KiB |
BIN
public/canvas/workflows/shot01.jpeg
Executable file
BIN
public/canvas/workflows/shot01.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 936 KiB |
BIN
public/canvas/workflows/workflow01.jpeg
Executable file
BIN
public/canvas/workflows/workflow01.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/canvas/workflows/workflow02.jpeg
Executable file
BIN
public/canvas/workflows/workflow02.jpeg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 902 KiB |
@@ -75,6 +75,7 @@ type ImportContext = {
|
||||
apiKeyIdMap: Map<string, string>;
|
||||
apiKeyOwnerIdMap: Map<string, string>;
|
||||
columnCache: Map<string, Set<string>>;
|
||||
defaultableColumnCache: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -136,11 +137,15 @@ async function importRows(
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
const existingColumns = await getExistingColumns(client, table, context);
|
||||
const defaultableColumns = await getDefaultableColumns(client, table, context);
|
||||
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
||||
|
||||
for (const rawRow of rows) {
|
||||
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
||||
const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col));
|
||||
const cols = Object.keys(row).filter(col => (
|
||||
effectiveAllowedColumns.includes(col)
|
||||
&& !(row[col] == null && defaultableColumns.has(col))
|
||||
));
|
||||
if (!cols.includes('id') || cols.length === 0) {
|
||||
skipped++;
|
||||
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
||||
@@ -235,7 +240,15 @@ async function buildImportContext(
|
||||
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
||||
}
|
||||
const ownerId = findImportedWorkUserId(row);
|
||||
const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() });
|
||||
const ownerByEmail = findUserIdByEmail(row, {
|
||||
userIdMap,
|
||||
workIdMap,
|
||||
emailUserIdMap,
|
||||
apiKeyIdMap,
|
||||
apiKeyOwnerIdMap,
|
||||
columnCache: new Map(),
|
||||
defaultableColumnCache: new Map(),
|
||||
});
|
||||
const mappedOwnerId = ownerId
|
||||
? (userIdMap.get(ownerId) || ownerId)
|
||||
: ownerByEmail;
|
||||
@@ -266,7 +279,15 @@ async function buildImportContext(
|
||||
}
|
||||
}
|
||||
|
||||
return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() };
|
||||
return {
|
||||
userIdMap,
|
||||
workIdMap,
|
||||
emailUserIdMap,
|
||||
apiKeyIdMap,
|
||||
apiKeyOwnerIdMap,
|
||||
columnCache: new Map(),
|
||||
defaultableColumnCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
||||
@@ -331,6 +352,12 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
|
||||
}
|
||||
|
||||
if (table === 'user_api_keys') {
|
||||
if (typeof next.note !== 'string' || next.note.trim() === '') {
|
||||
next.note = '导入的 API Key';
|
||||
}
|
||||
if (typeof next.type !== 'string' || next.type.trim() === '') {
|
||||
next.type = 'image';
|
||||
}
|
||||
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
||||
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
||||
const secret = rawApiKey || rawEncrypted;
|
||||
@@ -519,6 +546,29 @@ async function getExistingColumns(
|
||||
return columns;
|
||||
}
|
||||
|
||||
async function getDefaultableColumns(
|
||||
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||
table: string,
|
||||
context: ImportContext,
|
||||
): Promise<Set<string>> {
|
||||
const cached = context.defaultableColumnCache.get(table);
|
||||
if (cached) return cached;
|
||||
|
||||
const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
|
||||
const result = await client.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = $2
|
||||
AND is_nullable = 'NO'
|
||||
AND column_default IS NOT NULL`,
|
||||
[schemaName, tableName],
|
||||
);
|
||||
const columns = new Set((result.rows || []).map((row: Record<string, unknown>) => String(row.column_name)));
|
||||
context.defaultableColumnCache.set(table, columns);
|
||||
return columns;
|
||||
}
|
||||
|
||||
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
||||
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
||||
map.set(value, crypto.randomUUID());
|
||||
|
||||
60
src/app/api/canvas/projects/[id]/route.ts
Executable file
60
src/app/api/canvas/projects/[id]/route.ts
Executable file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { deleteCanvasProject, getCanvasProject, updateCanvasProject } from '@/lib/canvas-store';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { normalizeCanvasState } from '@/lib/canvas-store';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
const project = await getCanvasProject(userId, id);
|
||||
if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ project });
|
||||
} catch (error) {
|
||||
console.error('[canvas/projects/:id] GET error:', error);
|
||||
return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const project = await updateCanvasProject(userId, id, {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
state: body.state ? normalizeCanvasState(body.state) : undefined,
|
||||
});
|
||||
|
||||
if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||
return NextResponse.json({ project });
|
||||
} catch (error) {
|
||||
console.error('[canvas/projects/:id] PUT error:', error);
|
||||
return NextResponse.json({ error: '保存画布项目失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
const deleted = await deleteCanvasProject(userId, id);
|
||||
if (!deleted) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[canvas/projects/:id] DELETE error:', error);
|
||||
return NextResponse.json({ error: '删除画布项目失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/canvas/projects/route.ts
Executable file
31
src/app/api/canvas/projects/route.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createCanvasProject, listCanvasProjects } from '@/lib/canvas-store';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const projects = await listCanvasProjects(userId);
|
||||
return NextResponse.json({ projects });
|
||||
} catch (error) {
|
||||
console.error('[canvas/projects] GET error:', error);
|
||||
return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const title = typeof body.title === 'string' ? body.title : '未命名画布';
|
||||
const project = await createCanvasProject(userId, title);
|
||||
return NextResponse.json({ project }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('[canvas/projects] POST error:', error);
|
||||
return NextResponse.json({ error: '创建画布项目失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
5
src/app/canvas/page.tsx
Executable file
5
src/app/canvas/page.tsx
Executable file
@@ -0,0 +1,5 @@
|
||||
import { InfiniteCanvasWorkspace } from '@/components/canvas/infinite-canvas-workspace';
|
||||
|
||||
export default function CanvasPage() {
|
||||
return <InfiniteCanvasWorkspace />;
|
||||
}
|
||||
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
File diff suppressed because it is too large
Load Diff
553
src/components/canvas/react-flow-canvas.tsx
Executable file
553
src/components/canvas/react-flow-canvas.tsx
Executable file
@@ -0,0 +1,553 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
ConnectionLineType,
|
||||
Controls,
|
||||
Handle,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Node as FlowNode,
|
||||
type NodeChange,
|
||||
type NodeProps,
|
||||
type Rect,
|
||||
type ReactFlowInstance,
|
||||
type Viewport as FlowViewport,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Brush, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Type } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CanvasConnection, CanvasLayer, CanvasNode, CanvasViewport } from '@/lib/canvas-types';
|
||||
|
||||
type NodePositionCommit = { id: string; x: number; y: number };
|
||||
|
||||
type CanvasFlowNodeData = {
|
||||
node: CanvasNode;
|
||||
selected: boolean;
|
||||
connecting: boolean;
|
||||
connections: CanvasConnection[];
|
||||
allNodes: CanvasNode[];
|
||||
layerCanvasSize: number;
|
||||
layerColors: Record<CanvasLayer['type'], string>;
|
||||
onSelect: (id: string, additive: boolean) => void;
|
||||
onStartConnect: (id: string) => void;
|
||||
onRemoveConnection: (id: string) => void;
|
||||
};
|
||||
|
||||
type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
|
||||
|
||||
type ReactFlowCanvasProps = {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
viewport: CanvasViewport;
|
||||
selectedNodeIds: string[];
|
||||
connectingFromId: string | null;
|
||||
editable: boolean;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
layerCanvasSize: number;
|
||||
layerColors: Record<CanvasLayer['type'], string>;
|
||||
onSelectNode: (id: string, additive: boolean) => void;
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onStartConnect: (id: string) => void;
|
||||
onConnect: (sourceId: string, targetId: string) => void;
|
||||
onRemoveConnection: (id: string) => void;
|
||||
onRemoveConnections: (ids: string[]) => void;
|
||||
onNodesCommit: (positions: NodePositionCommit[], options?: { history?: boolean; dirty?: boolean }) => void;
|
||||
onViewportCommit: (viewport: CanvasViewport) => void;
|
||||
onReady?: (controls: CanvasFlowControls | null) => void;
|
||||
onPaneClick: (point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => void;
|
||||
onPaneDoubleClick: (point: { x: number; y: number }) => void;
|
||||
};
|
||||
|
||||
export type CanvasFlowControls = {
|
||||
setViewport: (viewport: CanvasViewport) => Promise<boolean>;
|
||||
zoomTo: (zoom: number) => Promise<boolean>;
|
||||
fitBounds: (bounds: Rect, options?: { padding?: number; duration?: number }) => Promise<boolean>;
|
||||
getViewport: () => CanvasViewport;
|
||||
};
|
||||
|
||||
const CONNECTION_COLORS = ['#22c55e', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#14b8a6'];
|
||||
const SNAP_GRID: [number, number] = [20, 20];
|
||||
const MULTI_SELECTION_KEYS = ['Meta', 'Control', 'Shift'];
|
||||
const FLOW_FIT_VIEW_OPTIONS = { padding: 0.2 };
|
||||
const CANVAS_NODE_TYPES = { canvasNode: CanvasNodeCard };
|
||||
|
||||
function getNodeImageUrl(node?: CanvasNode | null) {
|
||||
if (!node) return '';
|
||||
if (node.type === 'image') return node.imageUrl || '';
|
||||
return node.selectedOutput || node.outputImages?.[0] || '';
|
||||
}
|
||||
|
||||
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
const { node, selected, connecting, connections, allNodes, layerCanvasSize, layerColors, onSelect, onStartConnect, onRemoveConnection } = data;
|
||||
const incomingImage = allNodes.find(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id && !!getNodeImageUrl(item)));
|
||||
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-canvas-node
|
||||
data-node-id={node.id}
|
||||
className={cn(
|
||||
'group h-full overflow-visible rounded-lg border transition-shadow',
|
||||
node.type === 'frame' ? 'bg-background/20 shadow-none' : 'bg-card shadow-sm',
|
||||
selected ? 'border-primary ring-2 ring-primary/20' : connecting ? 'border-emerald-500 ring-2 ring-emerald-500/20' : 'border-border',
|
||||
)}
|
||||
style={{ borderColor: node.type === 'frame' && !selected ? node.color || '#22c55e' : undefined }}
|
||||
onPointerDown={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('button,input,textarea,[role="combobox"],.nodrag')) return;
|
||||
onSelect(node.id, event.ctrlKey || event.metaKey || event.shiftKey);
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} id="input" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||
<Handle type="source" position={Position.Right} id="output" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'nodrag absolute right-0 top-1/2 z-20 flex h-7 w-7 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
|
||||
connecting ? 'border-emerald-500 bg-emerald-500 text-white opacity-100' : '',
|
||||
)}
|
||||
title="从此模块开始连线"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartConnect(node.id);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="h-full overflow-hidden rounded-lg">
|
||||
<div className="flex h-10 items-center justify-between border-b bg-muted/40 px-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
{node.type === 'text' ? <Type className="h-4 w-4" /> : null}
|
||||
{node.type === 'image' ? <ImageIcon className="h-4 w-4" /> : null}
|
||||
{node.type === 'text2img' ? <Brush className="h-4 w-4" /> : null}
|
||||
{node.type === 'img2img' ? <FileImage className="h-4 w-4" /> : null}
|
||||
{node.type === 'layeredImage' ? <Layers className="h-4 w-4" /> : null}
|
||||
{node.type === 'frame' ? <Maximize2 className="h-4 w-4" /> : null}
|
||||
<span className="truncate">{node.title}</span>
|
||||
</div>
|
||||
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(100%-2.5rem)] p-3">
|
||||
{node.type === 'text' ? (
|
||||
<div className="h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-sm leading-6">{node.text}</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'image' ? (
|
||||
<div className="h-full overflow-hidden rounded-md bg-muted">
|
||||
{node.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={node.imageUrl} alt={node.title} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">未选择图片</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'text2img' || node.type === 'img2img' ? (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="line-clamp-3 min-h-14 rounded-md bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
{node.prompt || (connections.some(connection => connection.targetNodeId === node.id) ? '已连接上游内容,可继续补充描述' : '在右侧输入创作描述')}
|
||||
</div>
|
||||
{node.type === 'img2img' ? (
|
||||
<div className="h-20 overflow-hidden rounded-md border bg-muted">
|
||||
{node.referenceImage || getNodeImageUrl(incomingImage) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={node.referenceImage || getNodeImageUrl(incomingImage)} alt="参考图" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">从右侧选择参考图或连接图片节点</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
|
||||
{node.selectedOutput ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={node.selectedOutput} alt="生成结果" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待生成'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'layeredImage' ? (
|
||||
<div className="grid h-full grid-cols-[1fr_120px] gap-3 rounded-md border bg-muted/20 p-3">
|
||||
<div className="relative overflow-hidden rounded-md border bg-background">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted))_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted))_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0px] opacity-35" />
|
||||
{(node.layers || []).filter(layer => layer.visible).map(layer => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
'absolute border border-white/60 shadow-sm',
|
||||
layer.type === 'text' ? 'flex items-center px-2 text-xs font-semibold' : 'rounded-md',
|
||||
layer.locked ? 'outline outline-1 outline-dashed outline-muted-foreground/70' : '',
|
||||
)}
|
||||
style={{
|
||||
left: `${(layer.x / layerCanvasSize) * 100}%`,
|
||||
top: `${(layer.y / layerCanvasSize) * 100}%`,
|
||||
width: `${(layer.width / layerCanvasSize) * 100}%`,
|
||||
height: `${(layer.height / layerCanvasSize) * 100}%`,
|
||||
backgroundColor: layer.type === 'text' ? 'transparent' : layer.color || layerColors[layer.type],
|
||||
color: layer.color || layerColors.text,
|
||||
opacity: layer.opacity ?? 1,
|
||||
}}
|
||||
>
|
||||
{layer.type === 'text' ? layer.text || layer.name : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 overflow-y-auto">
|
||||
{(node.layers || []).slice().reverse().map(layer => (
|
||||
<div key={layer.id} className="truncate rounded-md border bg-background px-2 py-1 text-[11px]">
|
||||
<span className={layer.visible ? '' : 'text-muted-foreground line-through'}>{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'frame' ? (
|
||||
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || '#22c55e' }}>
|
||||
<div className="rounded-md bg-background/80 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && nodeConnections.length > 0 ? (
|
||||
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
|
||||
<div className="mb-1 font-medium text-muted-foreground">相关连线</div>
|
||||
<div className="space-y-1">
|
||||
{nodeConnections.slice(0, 4).map(connection => {
|
||||
const source = allNodes.find(item => item.id === connection.sourceNodeId);
|
||||
const target = allNodes.find(item => item.id === connection.targetNodeId);
|
||||
return (
|
||||
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
|
||||
<span className="min-w-0 truncate">{source?.title || '未知模块'} → {target?.title || '未知模块'}</span>
|
||||
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}>删</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sameFlowNodePositions(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
const bMap = new Map(b.map(node => [node.id, node]));
|
||||
return a.every(node => {
|
||||
const next = bMap.get(node.id);
|
||||
return !!next && node.position.x === next.position.x && node.position.y === next.position.y;
|
||||
});
|
||||
}
|
||||
|
||||
function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
const bMap = new Map(b.map(node => [node.id, node]));
|
||||
return a.every(node => {
|
||||
const next = bMap.get(node.id);
|
||||
return !!next
|
||||
&& node.position.x === next.position.x
|
||||
&& node.position.y === next.position.y
|
||||
&& node.selected === next.selected
|
||||
&& node.draggable === next.draggable
|
||||
&& node.selectable === next.selectable
|
||||
&& node.width === next.width
|
||||
&& node.height === next.height
|
||||
&& node.data.node === next.data.node
|
||||
&& node.data.selected === next.data.selected
|
||||
&& node.data.connecting === next.data.connecting
|
||||
&& node.data.connections === next.data.connections
|
||||
&& node.data.allNodes === next.data.allNodes;
|
||||
});
|
||||
}
|
||||
|
||||
function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number): CanvasViewport {
|
||||
return {
|
||||
x: viewport.x,
|
||||
y: viewport.y,
|
||||
zoom: Math.min(maxZoom, Math.max(minZoom, viewport.zoom)),
|
||||
};
|
||||
}
|
||||
|
||||
function FlowCanvasInner({
|
||||
nodes,
|
||||
connections,
|
||||
viewport,
|
||||
selectedNodeIds,
|
||||
connectingFromId,
|
||||
editable,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
layerCanvasSize,
|
||||
layerColors,
|
||||
onSelectNode,
|
||||
onSelectionChange,
|
||||
onStartConnect,
|
||||
onConnect,
|
||||
onRemoveConnection,
|
||||
onRemoveConnections,
|
||||
onNodesCommit,
|
||||
onViewportCommit,
|
||||
onReady,
|
||||
onPaneClick,
|
||||
onPaneDoubleClick,
|
||||
}: ReactFlowCanvasProps) {
|
||||
const reactFlow = useReactFlow<CanvasFlowNode, Edge>();
|
||||
const draggingRef = useRef(false);
|
||||
const movingRef = useRef(false);
|
||||
const paneClickTimerRef = useRef<number | null>(null);
|
||||
|
||||
const incomingNodes = useMemo<CanvasFlowNode[]>(() => nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: 'canvasNode',
|
||||
position: { x: node.x, y: node.y },
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
selected: selectedNodeIds.includes(node.id),
|
||||
draggable: editable,
|
||||
selectable: editable,
|
||||
zIndex: node.type === 'frame' ? Math.min(node.zIndex, 0) : node.zIndex,
|
||||
data: {
|
||||
node,
|
||||
selected: selectedNodeIds.includes(node.id),
|
||||
connecting: connectingFromId === node.id,
|
||||
connections,
|
||||
allNodes: nodes,
|
||||
layerCanvasSize,
|
||||
layerColors,
|
||||
onSelect: onSelectNode,
|
||||
onStartConnect,
|
||||
onRemoveConnection,
|
||||
},
|
||||
style: {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
},
|
||||
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, nodes, onRemoveConnection, onSelectNode, onStartConnect, selectedNodeIds]);
|
||||
|
||||
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
|
||||
|
||||
const flowEdges = useMemo<Edge[]>(() => connections.map((connection, index) => ({
|
||||
id: connection.id,
|
||||
source: connection.sourceNodeId,
|
||||
target: connection.targetNodeId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
label: connection.label,
|
||||
type: 'smoothstep',
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
style: {
|
||||
stroke: CONNECTION_COLORS[index % CONNECTION_COLORS.length],
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
labelBgStyle: { fill: 'hsl(var(--background))', fillOpacity: 0.9 },
|
||||
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 12 },
|
||||
})), [connections]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlowNodes(current => {
|
||||
if (draggingRef.current && sameFlowNodePositions(current, incomingNodes)) return current;
|
||||
if (sameFlowNodes(current, incomingNodes)) return current;
|
||||
return incomingNodes;
|
||||
});
|
||||
}, [incomingNodes]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (paneClickTimerRef.current !== null) {
|
||||
window.clearTimeout(paneClickTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
onReady?.(null);
|
||||
}, [onReady]);
|
||||
|
||||
const commitCurrentNodePositions = useCallback((options?: { history?: boolean; dirty?: boolean }) => {
|
||||
const positions = reactFlow.getNodes().map(node => ({
|
||||
id: node.id,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
}));
|
||||
onNodesCommit(positions, options);
|
||||
}, [onNodesCommit, reactFlow]);
|
||||
|
||||
const handleNodesChange = useCallback((changes: NodeChange<CanvasFlowNode>[]) => {
|
||||
const relevantChanges = changes.filter(change => change.type === 'position' || change.type === 'select' || change.type === 'remove');
|
||||
if (relevantChanges.length === 0) return;
|
||||
|
||||
setFlowNodes(current => {
|
||||
const removedIds = new Set(
|
||||
relevantChanges
|
||||
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'remove' }> => change.type === 'remove')
|
||||
.map(change => change.id),
|
||||
);
|
||||
let changed = removedIds.size > 0;
|
||||
const nextNodes = current.map(node => {
|
||||
if (removedIds.has(node.id)) return node;
|
||||
const positionChange = relevantChanges.find(
|
||||
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'position' }> => change.type === 'position' && change.id === node.id && !!change.position,
|
||||
);
|
||||
const selectChange = relevantChanges.find(
|
||||
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.id === node.id,
|
||||
);
|
||||
const nextPosition = positionChange?.position || node.position;
|
||||
const nextSelected = typeof selectChange?.selected === 'boolean' ? selectChange.selected : node.selected;
|
||||
if (nextPosition === node.position && nextSelected === node.selected) return node;
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
position: nextPosition,
|
||||
selected: nextSelected,
|
||||
};
|
||||
}).filter(node => !removedIds.has(node.id));
|
||||
return changed ? nextNodes : current;
|
||||
});
|
||||
|
||||
const selected = relevantChanges
|
||||
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.selected)
|
||||
.map(change => change.id);
|
||||
if (selected.length > 0) onSelectionChange(selected);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const handleEdgesChange = useCallback((changes: EdgeChange<Edge>[]) => {
|
||||
const removedIds = changes.filter(change => change.type === 'remove').map(change => change.id);
|
||||
if (removedIds.length > 0) onRemoveConnections(removedIds);
|
||||
}, [onRemoveConnections]);
|
||||
|
||||
const handleConnect = useCallback((connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return;
|
||||
onConnect(connection.source, connection.target);
|
||||
}, [onConnect]);
|
||||
|
||||
const handleMoveEnd = useCallback((_event: MouseEvent | TouchEvent | null, nextViewport: FlowViewport) => {
|
||||
if (!movingRef.current) return;
|
||||
movingRef.current = false;
|
||||
const committedViewport = clampViewport(nextViewport, minZoom, maxZoom);
|
||||
onViewportCommit(committedViewport);
|
||||
}, [maxZoom, minZoom, onViewportCommit]);
|
||||
|
||||
const handleNodeDragStart = useCallback(() => {
|
||||
draggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleNodeDragStop = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
commitCurrentNodePositions({ history: true, dirty: true });
|
||||
}, [commitCurrentNodePositions]);
|
||||
|
||||
const handleMoveStart = useCallback(() => {
|
||||
movingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handlePaneReactFlowClick = useCallback((event: React.MouseEvent) => {
|
||||
const point = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
if (event.detail >= 2) {
|
||||
if (paneClickTimerRef.current !== null) {
|
||||
window.clearTimeout(paneClickTimerRef.current);
|
||||
paneClickTimerRef.current = null;
|
||||
}
|
||||
onPaneDoubleClick(reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY }, { snapToGrid: true, snapGrid: SNAP_GRID }));
|
||||
return;
|
||||
}
|
||||
if (paneClickTimerRef.current !== null) {
|
||||
window.clearTimeout(paneClickTimerRef.current);
|
||||
}
|
||||
paneClickTimerRef.current = window.setTimeout(() => {
|
||||
paneClickTimerRef.current = null;
|
||||
onPaneClick(point, event);
|
||||
}, 180);
|
||||
}, [onPaneClick, onPaneDoubleClick, reactFlow]);
|
||||
|
||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: CanvasFlowNode[] }) => {
|
||||
onSelectionChange(selectedNodes.map(node => node.id));
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const getMiniMapNodeColor = useCallback((node: FlowNode) => (
|
||||
selectedNodeIds.includes(node.id) ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'
|
||||
), [selectedNodeIds]);
|
||||
|
||||
const handleInit = useCallback((instance: ReactFlowInstance<CanvasFlowNode, Edge>) => {
|
||||
onReady?.({
|
||||
setViewport: (nextViewport) => instance.setViewport(nextViewport, { duration: 120 }),
|
||||
zoomTo: (zoom) => instance.zoomTo(Math.min(maxZoom, Math.max(minZoom, zoom)), { duration: 120 }),
|
||||
fitBounds: (bounds, options) => instance.fitBounds(bounds, options),
|
||||
getViewport: () => clampViewport(instance.getViewport(), minZoom, maxZoom),
|
||||
});
|
||||
}, [maxZoom, minZoom, onReady]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
className="canvas-flow"
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
nodeTypes={CANVAS_NODE_TYPES}
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
snapToGrid
|
||||
snapGrid={SNAP_GRID}
|
||||
defaultViewport={viewport}
|
||||
zoomOnScroll
|
||||
zoomOnPinch
|
||||
panOnScroll={false}
|
||||
panOnDrag
|
||||
selectionOnDrag={false}
|
||||
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
|
||||
deleteKeyCode={null}
|
||||
zoomOnDoubleClick={false}
|
||||
nodesDraggable={editable}
|
||||
nodesConnectable={editable}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
fitViewOptions={FLOW_FIT_VIEW_OPTIONS}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onMoveStart={handleMoveStart}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
onPaneClick={handlePaneReactFlowClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onInit={handleInit}
|
||||
>
|
||||
<Background gap={20} size={1} color="hsl(var(--border))" />
|
||||
<Background gap={100} size={1.2} color="hsl(var(--muted-foreground))" />
|
||||
<Controls showInteractive={false} position="bottom-left" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={getMiniMapNodeColor}
|
||||
maskColor="hsl(var(--background) / 0.58)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReactFlowCanvas(props: ReactFlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Sparkles,
|
||||
LogOut,
|
||||
Shield,
|
||||
PanelsTopLeft,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
@@ -26,6 +27,7 @@ const navItems = [
|
||||
{ href: '/', label: '首页', icon: Sparkles },
|
||||
{ href: '/create', label: '创作', icon: Brush },
|
||||
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
|
||||
{ href: '/canvas', label: '画布', icon: PanelsTopLeft },
|
||||
{ href: '/profile', label: '我的', icon: User },
|
||||
];
|
||||
|
||||
|
||||
150
src/lib/canvas-store.ts
Executable file
150
src/lib/canvas-store.ts
Executable file
@@ -0,0 +1,150 @@
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { createEmptyCanvasState, type CanvasProject, type CanvasProjectState } from '@/lib/canvas-types';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeCanvasState(value: unknown): CanvasProjectState {
|
||||
if (!isRecord(value)) return createEmptyCanvasState();
|
||||
const fallback = createEmptyCanvasState();
|
||||
const viewport = isRecord(value.viewport) ? value.viewport : fallback.viewport;
|
||||
return {
|
||||
nodes: Array.isArray(value.nodes) ? value.nodes as CanvasProjectState['nodes'] : [],
|
||||
connections: Array.isArray(value.connections) ? value.connections as CanvasProjectState['connections'] : [],
|
||||
assets: Array.isArray(value.assets) ? value.assets as CanvasProjectState['assets'] : [],
|
||||
viewport: {
|
||||
x: typeof viewport.x === 'number' ? viewport.x : 0,
|
||||
y: typeof viewport.y === 'number' ? viewport.y : 0,
|
||||
zoom: typeof viewport.zoom === 'number' ? Math.min(3, Math.max(0.2, viewport.zoom)) : 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureCanvasSchema() {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
title text NOT NULL DEFAULT '未命名画布',
|
||||
state jsonb NOT NULL DEFAULT '{"nodes":[],"assets":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_canvas_projects_user_updated ON canvas_projects(user_id, updated_at DESC)');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function mapProject(row: Record<string, unknown>): CanvasProject {
|
||||
return {
|
||||
id: String(row.id || ''),
|
||||
userId: String(row.user_id || ''),
|
||||
title: String(row.title || '未命名画布'),
|
||||
state: normalizeCanvasState(row.state),
|
||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at || ''),
|
||||
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCanvasProjects(userId: string): Promise<CanvasProject[]> {
|
||||
await ensureCanvasSchema();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, user_id, title, state, created_at, updated_at
|
||||
FROM canvas_projects
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 50`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows.map(mapProject);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCanvasProject(userId: string, title = '未命名画布'): Promise<CanvasProject> {
|
||||
await ensureCanvasSchema();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
const result = await client.query(
|
||||
`INSERT INTO canvas_projects (id, user_id, title, state)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
RETURNING id, user_id, title, state, created_at, updated_at`,
|
||||
[id, userId, title.trim() || '未命名画布', JSON.stringify(createEmptyCanvasState())],
|
||||
);
|
||||
return mapProject(result.rows[0]);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCanvasProject(userId: string, id: string): Promise<CanvasProject | null> {
|
||||
await ensureCanvasSchema();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, user_id, title, state, created_at, updated_at
|
||||
FROM canvas_projects
|
||||
WHERE id = $1 AND user_id = $2
|
||||
LIMIT 1`,
|
||||
[id, userId],
|
||||
);
|
||||
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCanvasProject(
|
||||
userId: string,
|
||||
id: string,
|
||||
values: { title?: string; state?: CanvasProjectState },
|
||||
): Promise<CanvasProject | null> {
|
||||
await ensureCanvasSchema();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const current = await client.query(
|
||||
'SELECT title, state FROM canvas_projects WHERE id = $1 AND user_id = $2 LIMIT 1',
|
||||
[id, userId],
|
||||
);
|
||||
if (current.rows.length === 0) return null;
|
||||
|
||||
const title = typeof values.title === 'string' && values.title.trim()
|
||||
? values.title.trim()
|
||||
: current.rows[0].title;
|
||||
const state = values.state ? normalizeCanvasState(values.state) : normalizeCanvasState(current.rows[0].state);
|
||||
const result = await client.query(
|
||||
`UPDATE canvas_projects
|
||||
SET title = $3, state = $4::jsonb, updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, user_id, title, state, created_at, updated_at`,
|
||||
[id, userId, title, JSON.stringify(state)],
|
||||
);
|
||||
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCanvasProject(userId: string, id: string): Promise<boolean> {
|
||||
await ensureCanvasSchema();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
'DELETE FROM canvas_projects WHERE id = $1 AND user_id = $2',
|
||||
[id, userId],
|
||||
);
|
||||
return (result.rowCount || 0) > 0;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
97
src/lib/canvas-types.ts
Executable file
97
src/lib/canvas-types.ts
Executable file
@@ -0,0 +1,97 @@
|
||||
export type CanvasNodeType = 'text' | 'image' | 'text2img' | 'img2img' | 'layeredImage' | 'frame';
|
||||
|
||||
export type CanvasViewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
export type CanvasAsset = {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
type: 'image';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CanvasConnection = {
|
||||
id: string;
|
||||
sourceNodeId: string;
|
||||
targetNodeId: string;
|
||||
label?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CanvasLayer = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'background' | 'element' | 'icon' | 'text' | 'effect';
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
assetUrl?: string;
|
||||
text?: string;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type CanvasNode = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
title: string;
|
||||
color?: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
text?: string;
|
||||
imageUrl?: string;
|
||||
referenceImage?: string;
|
||||
outputImages?: string[];
|
||||
selectedOutput?: string;
|
||||
status?: 'idle' | 'generating' | 'failed' | 'succeeded';
|
||||
error?: string;
|
||||
params?: {
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
count?: number;
|
||||
strength?: number;
|
||||
model?: string;
|
||||
modelLabel?: string;
|
||||
apiType?: 'stream' | 'sync';
|
||||
};
|
||||
layers?: CanvasLayer[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CanvasProjectState = {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
assets: CanvasAsset[];
|
||||
viewport: CanvasViewport;
|
||||
};
|
||||
|
||||
export type CanvasProject = {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
state: CanvasProjectState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function createEmptyCanvasState(): CanvasProjectState {
|
||||
return {
|
||||
nodes: [],
|
||||
connections: [],
|
||||
assets: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user