feat: implement My Trees, admin UI, rating modal, and bundle optimization (Issues #15, #18, #19, #31)
Frontend features: - My Trees personal dashboard with fork tracking (Issue #15) - Tree sharing UI with token generation and copy (Issue #16) - Draft tree badges and validation UI (Issue #25) - Save session as tree modal (Issue #17) - Rate/review modal with localStorage tracking (Issue #19) - Admin category management with drag-and-drop (Issue #18) - Bundle size optimization with code splitting (Issue #31) Components created: - MyTreesPage: Personal tree organization - AdminCategoriesPage: Category CRUD with @dnd-kit - ShareTreeModal: Tree sharing interface - SaveSessionAsTreeModal: Session conversion UI - StepRatingModal: Post-session rating with stars - StarRating: Reusable rating component - PageLoader: Loading fallback for lazy routes - CreateCategoryModal, EditCategoryModal: Admin modals Bundle optimization: - Reduced from 892 KB to 221 KB (75% reduction) - Dynamic imports for 9 heavy pages - Vendor chunk splitting for optimal caching - 6 separate vendor chunks (react, markdown, utils, dnd, icons, state) Dependencies added: - @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities API clients: - stepCategories: Full CRUD for admin - Enhanced sessions: saveAsTree endpoint - Enhanced trees: share, fork, canPublish endpoints Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
376
frontend/package-lock.json
generated
376
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -40,7 +43,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vite-bundle-visualizer": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -344,6 +348,59 @@
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
@@ -1563,7 +1620,6 @@
|
||||
"version": "19.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1921,6 +1977,16 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -2137,6 +2203,16 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -2308,6 +2384,21 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2428,7 +2519,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
@@ -2484,6 +2574,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -2550,6 +2650,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -3087,6 +3194,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -3315,6 +3432,31 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/import-from-esm": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz",
|
||||
"integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"import-meta-resolve": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20"
|
||||
}
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -3394,6 +3536,22 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3404,6 +3562,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -3449,6 +3617,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -4375,6 +4556,24 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
|
||||
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-lazy-prop": "^2.0.0",
|
||||
"is-docker": "^2.1.1",
|
||||
"is-wsl": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4936,6 +5135,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -5023,6 +5232,37 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-plugin-visualizer": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz",
|
||||
"integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"open": "^8.4.0",
|
||||
"picomatch": "^4.0.2",
|
||||
"source-map": "^0.7.4",
|
||||
"yargs": "^17.5.1"
|
||||
},
|
||||
"bin": {
|
||||
"rollup-plugin-visualizer": "dist/bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rolldown": "1.x",
|
||||
"rollup": "2.x || 3.x || 4.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -5102,6 +5342,16 @@
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -5122,6 +5372,21 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -5136,6 +5401,19 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -5314,6 +5592,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -5367,6 +5655,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -5663,6 +5957,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-bundle-visualizer": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-bundle-visualizer/-/vite-bundle-visualizer-1.2.1.tgz",
|
||||
"integrity": "sha512-cwz/Pg6+95YbgIDp+RPwEToc4TKxfsFWSG/tsl2DSZd9YZicUag1tQXjJ5xcL7ydvEoaC2FOZeaXOU60t9BRXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"import-from-esm": "^1.3.3",
|
||||
"rollup-plugin-visualizer": "^5.11.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"vite-bundle-visualizer": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5689,6 +6002,34 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -5696,6 +6037,35 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"analyze": "vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -42,6 +46,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vite-bundle-visualizer": "^1.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport } from '@/types'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse } from '@/types'
|
||||
|
||||
export interface SessionListParams {
|
||||
page?: number
|
||||
@@ -58,6 +58,11 @@ export const sessionsApi = {
|
||||
const response = await apiClient.patch<Session>(`/sessions/${id}/scratchpad`, { scratchpad: content })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async saveAsTree(id: string, data: SaveAsTreeRequest): Promise<SaveAsTreeResponse> {
|
||||
const response = await apiClient.post<SaveAsTreeResponse>(`/sessions/${id}/save-as-tree`, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default sessionsApi
|
||||
|
||||
@@ -1,15 +1,62 @@
|
||||
import apiClient from './client'
|
||||
import type { StepCategory } from '@/types/step'
|
||||
import type {
|
||||
StepCategory,
|
||||
StepCategoryListItem,
|
||||
StepCategoryCreate,
|
||||
StepCategoryUpdate
|
||||
} from '@/types'
|
||||
|
||||
export interface StepCategoryListParams {
|
||||
include_inactive?: boolean
|
||||
account_only?: boolean
|
||||
}
|
||||
|
||||
export const stepCategoriesApi = {
|
||||
async list(): Promise<StepCategory[]> {
|
||||
const response = await apiClient.get<StepCategory[]>('/step-categories')
|
||||
async list(params?: StepCategoryListParams): Promise<StepCategoryListItem[]> {
|
||||
const response = await apiClient.get<StepCategoryListItem[]>('/step-categories', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<StepCategory> {
|
||||
const response = await apiClient.get<StepCategory>(`/step-categories/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: StepCategoryCreate): Promise<StepCategory> {
|
||||
const response = await apiClient.post<StepCategory>('/step-categories', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: StepCategoryUpdate): Promise<StepCategory> {
|
||||
const response = await apiClient.put<StepCategory>(`/step-categories/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/step-categories/${id}`)
|
||||
},
|
||||
|
||||
async archive(id: string): Promise<StepCategory> {
|
||||
const response = await apiClient.put<StepCategory>(`/step-categories/${id}`, {
|
||||
is_active: false
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async restore(id: string): Promise<StepCategory> {
|
||||
const response = await apiClient.put<StepCategory>(`/step-categories/${id}`, {
|
||||
is_active: true
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateOrder(updates: Array<{ id: string; display_order: number }>): Promise<void> {
|
||||
// Update display_order for multiple categories
|
||||
await Promise.all(
|
||||
updates.map(({ id, display_order }) =>
|
||||
apiClient.put(`/step-categories/${id}`, { display_order })
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters } from '@/types'
|
||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters, TreeShareCreate, TreeShare, TreeVisibilityUpdate, SharedTree, TreeValidationResponse } from '@/types'
|
||||
|
||||
export const treesApi = {
|
||||
async list(params?: TreeFilters): Promise<TreeListItem[]> {
|
||||
@@ -38,6 +38,38 @@ export const treesApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async fork(id: string, data?: { fork_reason?: string; name?: string }): Promise<Tree> {
|
||||
const response = await apiClient.post<Tree>(`/trees/${id}/fork`, data || {})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Tree sharing
|
||||
async createShare(id: string, data: TreeShareCreate): Promise<TreeShare> {
|
||||
const response = await apiClient.post<TreeShare>(`/trees/${id}/share`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listShares(id: string): Promise<TreeShare[]> {
|
||||
const response = await apiClient.get<TreeShare[]>(`/trees/${id}/shares`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateVisibility(id: string, data: TreeVisibilityUpdate): Promise<Tree> {
|
||||
const response = await apiClient.patch<Tree>(`/trees/${id}/visibility`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getSharedTree(shareToken: string): Promise<SharedTree> {
|
||||
const response = await apiClient.get<SharedTree>(`/shared/${shareToken}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Tree validation
|
||||
async canPublish(id: string): Promise<TreeValidationResponse> {
|
||||
const response = await apiClient.post<TreeValidationResponse>(`/trees/${id}/can-publish`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default treesApi
|
||||
|
||||
116
frontend/src/components/admin/CategoryRow.tsx
Normal file
116
frontend/src/components/admin/CategoryRow.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { GripVertical, Edit, Archive, RotateCcw } from 'lucide-react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { StepCategoryListItem } from '@/types'
|
||||
|
||||
interface CategoryRowProps {
|
||||
category: StepCategoryListItem
|
||||
stepCount: number
|
||||
onEdit: (category: StepCategoryListItem) => void
|
||||
onArchive: (id: string) => void
|
||||
onRestore: (id: string) => void
|
||||
}
|
||||
|
||||
export function CategoryRow({
|
||||
category,
|
||||
stepCount,
|
||||
onEdit,
|
||||
onArchive,
|
||||
onRestore
|
||||
}: CategoryRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: category.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border border-border bg-card p-4',
|
||||
isDragging && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Category Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-foreground">{category.name}</h3>
|
||||
{!category.is_active && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{category.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{category.description}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stepCount} step{stepCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(category)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit category"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{category.is_active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(category.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Archive category"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRestore(category.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Restore category"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
frontend/src/components/admin/CreateCategoryModal.tsx
Normal file
159
frontend/src/components/admin/CreateCategoryModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CreateCategoryModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: { name: string; description: string }) => Promise<void>
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function CreateCategoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSaving = false
|
||||
}: CreateCategoryModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Category name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
setError('Category name must be 100 characters or less')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim()
|
||||
})
|
||||
// Reset form on success
|
||||
setName('')
|
||||
setDescription('')
|
||||
} catch (err) {
|
||||
setError('Failed to create category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Create Category</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Category Name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
maxLength={100}
|
||||
placeholder="e.g., Network Troubleshooting"
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{name.length}/100 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description Field */}
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
placeholder="Brief description of this category..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim()}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Creating...' : 'Create Category'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
frontend/src/components/admin/EditCategoryModal.tsx
Normal file
165
frontend/src/components/admin/EditCategoryModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { StepCategoryListItem } from '@/types'
|
||||
|
||||
interface EditCategoryModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: { name: string; description: string }) => Promise<void>
|
||||
category: StepCategoryListItem | null
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function EditCategoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
category,
|
||||
isSaving = false
|
||||
}: EditCategoryModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Pre-populate form when category changes
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setName(category.name)
|
||||
setDescription(category.description || '')
|
||||
}
|
||||
}, [category])
|
||||
|
||||
if (!isOpen || !category) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Category name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
setError('Category name must be 100 characters or less')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim()
|
||||
})
|
||||
} catch (err) {
|
||||
setError('Failed to update category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Edit Category</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Category Name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
maxLength={100}
|
||||
placeholder="e.g., Network Troubleshooting"
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{name.length}/100 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description Field */}
|
||||
<div>
|
||||
<label htmlFor="edit-description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
placeholder="Brief description of this category..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim()}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
frontend/src/components/common/PageLoader.tsx
Normal file
12
frontend/src/components/common/PageLoader.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageLoader
|
||||
64
frontend/src/components/common/StarRating.tsx
Normal file
64
frontend/src/components/common/StarRating.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Star } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number
|
||||
onChange?: (value: number) => void
|
||||
readonly?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showCount?: boolean
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6'
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
size = 'md',
|
||||
showCount = false
|
||||
}: StarRatingProps) {
|
||||
const handleClick = (rating: number) => {
|
||||
if (!readonly && onChange) {
|
||||
onChange(rating)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => handleClick(star)}
|
||||
disabled={readonly}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
!readonly && 'hover:scale-110 cursor-pointer',
|
||||
readonly && 'cursor-default'
|
||||
)}
|
||||
aria-label={`${star} star${star !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
sizeClasses[size],
|
||||
star <= value
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-none text-muted-foreground',
|
||||
!readonly && 'hover:text-yellow-300'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{showCount && (
|
||||
<span className="ml-1 text-sm text-muted-foreground">
|
||||
({value}/5)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function AppLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { effectiveRole } = usePermissions()
|
||||
const { effectiveRole, isSuperAdmin } = usePermissions()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -48,9 +48,11 @@ export function AppLayout() {
|
||||
|
||||
const navItems = [
|
||||
{ path: '/trees', label: 'Trees' },
|
||||
{ path: '/my-trees', label: 'My Trees' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
{ path: '/account', label: 'Account' },
|
||||
{ path: '/settings', label: 'Settings' },
|
||||
...(isSuperAdmin ? [{ path: '/admin/categories', label: 'Admin: Categories' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
279
frontend/src/components/library/ShareTreeModal.tsx
Normal file
279
frontend/src/components/library/ShareTreeModal.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
|
||||
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
|
||||
import { treesApi } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface ShareTreeModalProps {
|
||||
tree: TreeListItem
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [shares, setShares] = useState<TreeShare[]>([])
|
||||
const [activeShare, setActiveShare] = useState<TreeShare | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [allowForking, setAllowForking] = useState(true)
|
||||
const [visibility, setVisibility] = useState<TreeVisibility>('private')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadShares()
|
||||
// Reset state
|
||||
setCopied(false)
|
||||
setAllowForking(true)
|
||||
}
|
||||
}, [isOpen, tree.id])
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
const sharesData = await treesApi.listShares(tree.id)
|
||||
setShares(sharesData)
|
||||
// Set active share to most recent
|
||||
if (sharesData.length > 0) {
|
||||
setActiveShare(sharesData[0])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateLink = async () => {
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const newShare = await treesApi.createShare(tree.id, {
|
||||
allow_forking: allowForking,
|
||||
})
|
||||
setShares([newShare, ...shares])
|
||||
setActiveShare(newShare)
|
||||
toast.success('Share link generated')
|
||||
} catch (err) {
|
||||
console.error('Failed to generate share link:', err)
|
||||
toast.error('Failed to generate share link')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!activeShare) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(activeShare.share_url)
|
||||
setCopied(true)
|
||||
toast.success('Link copied to clipboard')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err)
|
||||
toast.error('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisibilityChange = async (newVisibility: TreeVisibility) => {
|
||||
try {
|
||||
await treesApi.updateVisibility(tree.id, { visibility: newVisibility })
|
||||
setVisibility(newVisibility)
|
||||
toast.success('Visibility updated')
|
||||
} catch (err) {
|
||||
console.error('Failed to update visibility:', err)
|
||||
toast.error('Failed to update visibility')
|
||||
}
|
||||
}
|
||||
|
||||
const getVisibilityIcon = (level: TreeVisibility) => {
|
||||
switch (level) {
|
||||
case 'private':
|
||||
return <Lock className="h-4 w-4" />
|
||||
case 'team':
|
||||
return <Users className="h-4 w-4" />
|
||||
case 'link':
|
||||
return <Link2 className="h-4 w-4" />
|
||||
case 'public':
|
||||
return <Globe className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
const getVisibilityDescription = (level: TreeVisibility) => {
|
||||
switch (level) {
|
||||
case 'private':
|
||||
return 'Only you can access'
|
||||
case 'team':
|
||||
return 'Team members can access'
|
||||
case 'link':
|
||||
return 'Anyone with the link'
|
||||
case 'public':
|
||||
return 'Discoverable by everyone'
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg rounded-lg border border-border bg-card shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-card-foreground">Share Tree</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* Tree Info */}
|
||||
<div>
|
||||
<h3 className="font-medium text-card-foreground">{tree.name}</h3>
|
||||
{tree.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visibility Settings */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-card-foreground">
|
||||
Visibility
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{(['private', 'team', 'link', 'public'] as TreeVisibility[]).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => handleVisibilityChange(level)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === level
|
||||
? 'border-primary bg-primary/5 text-card-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary/50 hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{getVisibilityIcon(level)}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium capitalize">{level}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getVisibilityDescription(level)}
|
||||
</div>
|
||||
</div>
|
||||
{visibility === level && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share Link Generation */}
|
||||
{visibility !== 'private' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-card-foreground">
|
||||
Share Link
|
||||
</label>
|
||||
|
||||
{/* Allow Forking Checkbox */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow-forking"
|
||||
checked={allowForking}
|
||||
onChange={(e) => setAllowForking(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="allow-forking"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Allow recipients to fork this tree
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
{!activeShare && (
|
||||
<button
|
||||
onClick={handleGenerateLink}
|
||||
disabled={isGenerating}
|
||||
className={cn(
|
||||
'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate Share Link'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Active Share Link */}
|
||||
{activeShare && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-background p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={activeShare.share_url}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-input px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
copied
|
||||
? 'border-green-500 bg-green-500/10 text-green-600'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeShare.allow_forking
|
||||
? 'Recipients can fork this tree'
|
||||
: 'Forking disabled for this share'}
|
||||
</p>
|
||||
{shares.length > 1 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{shares.length} active share links
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 border-t border-border px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md border border-input px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, Trash2 } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
@@ -12,6 +12,7 @@ interface TreeGridViewProps {
|
||||
onTagClick: (tag: string) => void
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
}
|
||||
|
||||
export function TreeGridView({
|
||||
@@ -20,6 +21,7 @@ export function TreeGridView({
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
}: TreeGridViewProps) {
|
||||
const { canEditTree, canDeleteTree } = usePermissions()
|
||||
|
||||
@@ -31,7 +33,15 @@ export function TreeGridView({
|
||||
className="rounded-lg border border-border bg-card p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
{tree.status === 'draft' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
<FileText className="h-3 w-3" />
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
@@ -66,6 +76,19 @@ export function TreeGridView({
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={onFolderCreated} />
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onForkTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Fork tree"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
@@ -12,6 +12,7 @@ interface TreeListViewProps {
|
||||
onTagClick: (tag: string) => void
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
}
|
||||
|
||||
export function TreeListView({
|
||||
@@ -19,6 +20,7 @@ export function TreeListView({
|
||||
onStartSession,
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
onForkTree,
|
||||
}: TreeListViewProps) {
|
||||
const { canEditTree } = usePermissions()
|
||||
|
||||
@@ -33,6 +35,12 @@ export function TreeListView({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-card-foreground truncate">{tree.name}</h3>
|
||||
{tree.status === 'draft' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 flex-shrink-0">
|
||||
<FileText className="h-3 w-3" />
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -71,6 +79,19 @@ export function TreeListView({
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={onFolderCreated} />
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onForkTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Fork tree"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { AddToFolderMenu } from './AddToFolderMenu'
|
||||
@@ -14,6 +14,7 @@ interface TreeTableViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onSortChange?: (sortBy: string) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
}
|
||||
|
||||
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
|
||||
@@ -24,6 +25,7 @@ export function TreeTableView({
|
||||
onTagClick,
|
||||
onFolderCreated,
|
||||
onSortChange,
|
||||
onForkTree,
|
||||
}: TreeTableViewProps) {
|
||||
const { canEditTree } = usePermissions()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||
@@ -135,6 +137,12 @@ export function TreeTableView({
|
||||
<span className="font-medium text-card-foreground truncate max-w-[200px]">
|
||||
{tree.name}
|
||||
</span>
|
||||
{tree.status === 'draft' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 flex-shrink-0">
|
||||
<FileText className="h-3 w-3" />
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -175,6 +183,19 @@ export function TreeTableView({
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={onFolderCreated} />
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onForkTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Fork tree"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
|
||||
159
frontend/src/components/session/SaveSessionAsTreeModal.tsx
Normal file
159
frontend/src/components/session/SaveSessionAsTreeModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SaveSessionAsTreeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: { tree_name?: string; description?: string; status: 'draft' | 'published' }) => Promise<void>
|
||||
defaultTreeName?: string
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function SaveSessionAsTreeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
defaultTreeName,
|
||||
isSaving = false
|
||||
}: SaveSessionAsTreeModalProps) {
|
||||
const [treeName, setTreeName] = useState(defaultTreeName || '')
|
||||
const [description, setDescription] = useState('')
|
||||
const [status, setStatus] = useState<'draft' | 'published'>('draft')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await onSave({
|
||||
tree_name: treeName.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
status
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Save Session as Tree</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Create a new tree from this session's path. The tree will be linked to the original tree as a fork.
|
||||
</p>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Tree Name */}
|
||||
<div>
|
||||
<label htmlFor="treeName" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Tree Name <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="treeName"
|
||||
type="text"
|
||||
value={treeName}
|
||||
onChange={(e) => setTreeName(e.target.value)}
|
||||
placeholder={defaultTreeName || "Auto-generated if left blank"}
|
||||
disabled={isSaving}
|
||||
maxLength={255}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description for this tree"
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="draft"
|
||||
checked={status === 'draft'}
|
||||
onChange={() => setStatus('draft')}
|
||||
disabled={isSaving}
|
||||
className="h-4 w-4 border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Draft</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="published"
|
||||
checked={status === 'published'}
|
||||
onChange={() => setStatus('published')}
|
||||
disabled={isSaving}
|
||||
className="h-4 w-4 border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Published</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save as Tree'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
frontend/src/components/session/StepRatingModal.tsx
Normal file
219
frontend/src/components/session/StepRatingModal.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react'
|
||||
import { X, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
import { StarRating } from '@/components/common/StarRating'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Step } from '@/types'
|
||||
|
||||
interface StepRatingData {
|
||||
rating: number
|
||||
helpful: boolean | null
|
||||
review: string
|
||||
}
|
||||
|
||||
interface StepRatingModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (ratings: Map<string, StepRatingData>) => Promise<void>
|
||||
librarySteps: Step[]
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function StepRatingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
librarySteps,
|
||||
isSaving = false
|
||||
}: StepRatingModalProps) {
|
||||
// Store ratings for each step
|
||||
const [ratings, setRatings] = useState<Map<string, StepRatingData>>(new Map())
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleRatingChange = (stepId: string, rating: number) => {
|
||||
setRatings(prev => {
|
||||
const updated = new Map(prev)
|
||||
const existing = updated.get(stepId) || { rating: 0, helpful: null, review: '' }
|
||||
updated.set(stepId, { ...existing, rating })
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const handleHelpfulChange = (stepId: string, helpful: boolean) => {
|
||||
setRatings(prev => {
|
||||
const updated = new Map(prev)
|
||||
const existing = updated.get(stepId) || { rating: 0, helpful: null, review: '' }
|
||||
// Toggle: if clicking same button, set to null
|
||||
const newHelpful = existing.helpful === helpful ? null : helpful
|
||||
updated.set(stepId, { ...existing, helpful: newHelpful })
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const handleReviewChange = (stepId: string, review: string) => {
|
||||
setRatings(prev => {
|
||||
const updated = new Map(prev)
|
||||
const existing = updated.get(stepId) || { rating: 0, helpful: null, review: '' }
|
||||
updated.set(stepId, { ...existing, review })
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Filter out steps with no rating
|
||||
const ratingsToSubmit = new Map(
|
||||
Array.from(ratings.entries()).filter(([_, data]) => data.rating > 0)
|
||||
)
|
||||
|
||||
if (ratingsToSubmit.size === 0) {
|
||||
// No ratings to submit, just close
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
await onSubmit(ratingsToSubmit)
|
||||
}
|
||||
|
||||
const getRating = (stepId: string) => ratings.get(stepId)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] flex flex-col rounded-lg border border-border bg-card shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Rate Your Experience</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Help others by rating the steps you used ({librarySteps.length} step{librarySteps.length !== 1 ? 's' : ''})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Steps List */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-6">
|
||||
{librarySteps.map((step) => {
|
||||
const rating = getRating(step.id)
|
||||
return (
|
||||
<div key={step.id} className="rounded-lg border border-border bg-background p-4">
|
||||
{/* Step Title */}
|
||||
<h3 className="font-medium text-foreground">{step.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground capitalize">{step.step_type}</p>
|
||||
|
||||
{/* Star Rating */}
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
Rating
|
||||
</label>
|
||||
<StarRating
|
||||
value={rating?.rating || 0}
|
||||
onChange={(value) => handleRatingChange(step.id, value)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Was this helpful? */}
|
||||
<div className="mt-3">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Was this helpful?
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHelpfulChange(step.id, true)}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors',
|
||||
rating?.helpful === true
|
||||
? 'border-green-500 bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'border-input bg-background text-foreground hover:bg-accent',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHelpfulChange(step.id, false)}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors',
|
||||
rating?.helpful === false
|
||||
? 'border-red-500 bg-red-500/10 text-red-600 dark:text-red-400'
|
||||
: 'border-input bg-background text-foreground hover:bg-accent',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Review */}
|
||||
<div className="mt-3">
|
||||
<label htmlFor={`review-${step.id}`} className="mb-1 block text-sm font-medium text-foreground">
|
||||
Review <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id={`review-${step.id}`}
|
||||
value={rating?.review || ''}
|
||||
onChange={(e) => handleReviewChange(step.id, e.target.value)}
|
||||
disabled={isSaving}
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
placeholder="Share your experience with this step..."
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-right">
|
||||
{rating?.review?.length || 0}/500
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-border px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSaving ? 'Submitting...' : 'Submit Ratings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
frontend/src/hooks/useTreeValidation.ts
Normal file
177
frontend/src/hooks/useTreeValidation.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { TreeStructure, ValidationError } from '@/types'
|
||||
|
||||
interface ValidationResult {
|
||||
canPublish: boolean
|
||||
errors: ValidationError[]
|
||||
warnings: ValidationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side tree validation hook
|
||||
* Validates tree structure before allowing publish
|
||||
*/
|
||||
export function useTreeValidation(
|
||||
name: string,
|
||||
description: string | null,
|
||||
treeStructure: TreeStructure | null
|
||||
): ValidationResult {
|
||||
return useMemo(() => {
|
||||
const errors: ValidationError[] = []
|
||||
const warnings: ValidationError[] = []
|
||||
|
||||
// Validate name
|
||||
if (!name || name.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'name',
|
||||
message: 'Tree name is required',
|
||||
})
|
||||
} else if (name.length > 255) {
|
||||
errors.push({
|
||||
field: 'name',
|
||||
message: 'Tree name must be 255 characters or less',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate tree structure exists
|
||||
if (!treeStructure) {
|
||||
errors.push({
|
||||
field: 'tree_structure',
|
||||
message: 'Tree structure is required',
|
||||
})
|
||||
// Can't validate further without a tree structure
|
||||
return {
|
||||
canPublish: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate root node
|
||||
if (!treeStructure.type) {
|
||||
errors.push({
|
||||
field: 'tree_structure.type',
|
||||
message: 'Root node must have a type',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate root node content based on type
|
||||
if (treeStructure.type === 'decision') {
|
||||
if (!treeStructure.question || treeStructure.question.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'tree_structure.question',
|
||||
message: 'Decision node must have a question',
|
||||
})
|
||||
}
|
||||
if (!treeStructure.options || treeStructure.options.length === 0) {
|
||||
errors.push({
|
||||
field: 'tree_structure.options',
|
||||
message: 'Decision node must have at least one option',
|
||||
})
|
||||
} else {
|
||||
// Validate each option
|
||||
treeStructure.options.forEach((option, index) => {
|
||||
if (!option.label || option.label.trim().length === 0) {
|
||||
errors.push({
|
||||
field: `tree_structure.options[${index}].label`,
|
||||
message: `Option ${index + 1} must have a label`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (treeStructure.type === 'action') {
|
||||
if (!treeStructure.title || treeStructure.title.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'tree_structure.title',
|
||||
message: 'Action node must have a title',
|
||||
})
|
||||
}
|
||||
if (!treeStructure.description || treeStructure.description.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'tree_structure.description',
|
||||
message: 'Action node must have a description',
|
||||
})
|
||||
}
|
||||
} else if (treeStructure.type === 'solution') {
|
||||
if (!treeStructure.title || treeStructure.title.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'tree_structure.title',
|
||||
message: 'Solution node must have a title',
|
||||
})
|
||||
}
|
||||
if (!treeStructure.description || treeStructure.description.trim().length === 0) {
|
||||
errors.push({
|
||||
field: 'tree_structure.description',
|
||||
message: 'Solution node must have a description',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate children recursively (basic check)
|
||||
const validateChildren = (node: TreeStructure, path: string = 'tree_structure') => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach((child, index) => {
|
||||
const childPath = `${path}.children[${index}]`
|
||||
|
||||
if (!child.type) {
|
||||
errors.push({
|
||||
field: `${childPath}.type`,
|
||||
message: 'Child node must have a type',
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively validate
|
||||
if (child.type === 'decision' && (!child.question || child.question.trim().length === 0)) {
|
||||
errors.push({
|
||||
field: `${childPath}.question`,
|
||||
message: 'Decision node must have a question',
|
||||
})
|
||||
}
|
||||
if ((child.type === 'action' || child.type === 'solution') &&
|
||||
(!child.title || child.title.trim().length === 0)) {
|
||||
errors.push({
|
||||
field: `${childPath}.title`,
|
||||
message: `${child.type} node must have a title`,
|
||||
})
|
||||
}
|
||||
|
||||
if (child.children) {
|
||||
validateChildren(child, childPath)
|
||||
}
|
||||
})
|
||||
} else if (node.type === 'decision' && (!node.options || node.options.length === 0)) {
|
||||
// Decision nodes without children should have had options validation above
|
||||
// This is just a warning for decision nodes that might be incomplete
|
||||
warnings.push({
|
||||
field: path,
|
||||
message: 'Decision node has no children (paths)',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
validateChildren(treeStructure)
|
||||
|
||||
// Warnings
|
||||
if (!description || description.trim().length === 0) {
|
||||
warnings.push({
|
||||
field: 'description',
|
||||
message: 'Adding a description helps users understand the tree purpose',
|
||||
})
|
||||
}
|
||||
|
||||
if (treeStructure.type === 'decision' &&
|
||||
treeStructure.children &&
|
||||
treeStructure.children.length < 2) {
|
||||
warnings.push({
|
||||
field: 'tree_structure',
|
||||
message: 'Tree has very few paths - consider adding more troubleshooting options',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
canPublish: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
}
|
||||
}, [name, description, treeStructure])
|
||||
}
|
||||
73
frontend/src/lib/sessionRatings.ts
Normal file
73
frontend/src/lib/sessionRatings.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Session ratings localStorage helper
|
||||
* Tracks which sessions have already been rated to prevent repeat prompts
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'patherly_rated_sessions'
|
||||
|
||||
interface RatedSessionsData {
|
||||
[sessionId: string]: {
|
||||
ratedAt: string // ISO timestamp
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has already been rated
|
||||
*/
|
||||
export function hasRatedSession(sessionId: string): boolean {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY)
|
||||
if (!data) return false
|
||||
|
||||
const rated: RatedSessionsData = JSON.parse(data)
|
||||
return sessionId in rated
|
||||
} catch (error) {
|
||||
console.error('Error checking rated sessions:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as rated
|
||||
*/
|
||||
export function markSessionRated(sessionId: string): void {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY)
|
||||
const rated: RatedSessionsData = data ? JSON.parse(data) : {}
|
||||
|
||||
rated[sessionId] = {
|
||||
ratedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(rated))
|
||||
} catch (error) {
|
||||
console.error('Error marking session as rated:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rated session tracking (for testing/debugging)
|
||||
*/
|
||||
export function clearRatedSessions(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (error) {
|
||||
console.error('Error clearing rated sessions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rated session IDs
|
||||
*/
|
||||
export function getRatedSessions(): string[] {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY)
|
||||
if (!data) return []
|
||||
|
||||
const rated: RatedSessionsData = JSON.parse(data)
|
||||
return Object.keys(rated)
|
||||
} catch (error) {
|
||||
console.error('Error getting rated sessions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
241
frontend/src/pages/AdminCategoriesPage.tsx
Normal file
241
frontend/src/pages/AdminCategoriesPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
|
||||
import { stepCategoriesApi, stepsApi } from '@/api'
|
||||
import { CategoryRow } from '@/components/admin/CategoryRow'
|
||||
import { CreateCategoryModal } from '@/components/admin/CreateCategoryModal'
|
||||
import { EditCategoryModal } from '@/components/admin/EditCategoryModal'
|
||||
import type { StepCategoryListItem } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function AdminCategoriesPage() {
|
||||
const [categories, setCategories] = useState<StepCategoryListItem[]>([])
|
||||
const [allSteps, setAllSteps] = useState<any[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<StepCategoryListItem | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [includeArchived, setIncludeArchived] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [includeArchived])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [categoriesData, stepsData] = await Promise.all([
|
||||
stepCategoriesApi.list({ include_inactive: includeArchived }),
|
||||
stepsApi.list({})
|
||||
])
|
||||
setCategories(categoriesData)
|
||||
setAllSteps(stepsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err)
|
||||
toast.error('Failed to load categories')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStepCount = (categoryId: string) => {
|
||||
return allSteps?.filter(s => s.category_id === categoryId).length || 0
|
||||
}
|
||||
|
||||
const handleCreate = async (data: { name: string; description: string }) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await stepCategoriesApi.create({
|
||||
name: data.name,
|
||||
description: data.description || undefined
|
||||
})
|
||||
toast.success('Category created successfully')
|
||||
setShowCreateModal(false)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to create category:', err)
|
||||
toast.error('Failed to create category')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (data: { name: string; description: string }) => {
|
||||
if (!editingCategory) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await stepCategoriesApi.update(editingCategory.id, {
|
||||
name: data.name,
|
||||
description: data.description || undefined
|
||||
})
|
||||
toast.success('Category updated successfully')
|
||||
setShowEditModal(false)
|
||||
setEditingCategory(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to update category:', err)
|
||||
toast.error('Failed to update category')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await stepCategoriesApi.archive(id)
|
||||
toast.success('Category archived')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to archive category:', err)
|
||||
toast.error('Failed to archive category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await stepCategoriesApi.restore(id)
|
||||
toast.success('Category restored')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Failed to restore category:', err)
|
||||
toast.error('Failed to restore category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = categories.findIndex(c => c.id === active.id)
|
||||
const newIndex = categories.findIndex(c => c.id === over.id)
|
||||
|
||||
const reordered = arrayMove(categories, oldIndex, newIndex)
|
||||
|
||||
// Optimistic update
|
||||
setCategories(reordered)
|
||||
|
||||
try {
|
||||
// Update display_order for all affected categories
|
||||
const updates = reordered.map((cat, index) => ({
|
||||
id: cat.id,
|
||||
display_order: index
|
||||
}))
|
||||
await stepCategoriesApi.updateOrder(updates)
|
||||
toast.success('Categories reordered')
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder categories:', err)
|
||||
toast.error('Failed to save order')
|
||||
// Revert on error
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (category: StepCategoryListItem) => {
|
||||
setEditingCategory(category)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">
|
||||
Step Categories
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage categories for organizing step library
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeArchived}
|
||||
onChange={(e) => setIncludeArchived(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show archived categories</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Categories List */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No categories found. Create your first category to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={categories.map(c => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{categories.map(category => (
|
||||
<CategoryRow
|
||||
key={category.id}
|
||||
category={category}
|
||||
stepCount={getStepCount(category.id)}
|
||||
onEdit={openEditModal}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<CreateCategoryModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<EditCategoryModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
category={editingCategory}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminCategoriesPage
|
||||
287
frontend/src/pages/MyTreesPage.tsx
Normal file
287
frontend/src/pages/MyTreesPage.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree } from 'lucide-react'
|
||||
import { treesApi, sessionsApi } from '@/api'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { ShareTreeModal } from '@/components/library/ShareTreeModal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface TreeWithStats extends TreeListItem {
|
||||
lastUsed?: string
|
||||
sessionCount?: number
|
||||
parent_tree_id?: string | null
|
||||
parent_tree_name?: string | null
|
||||
}
|
||||
|
||||
export function MyTreesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
const { canEditTree } = usePermissions()
|
||||
const [trees, setTrees] = useState<TreeWithStats[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [treeToDelete, setTreeToDelete] = useState<TreeWithStats | null>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMyTrees()
|
||||
}, [user?.id])
|
||||
|
||||
const loadMyTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Get user's trees (authored by current user)
|
||||
const userTrees = await treesApi.list({ author_id: user.id })
|
||||
|
||||
// Load session stats for each tree
|
||||
const treesWithStats = await Promise.all(
|
||||
userTrees.map(async (tree) => {
|
||||
try {
|
||||
const sessions = await sessionsApi.list({ tree_id: tree.id })
|
||||
const lastUsed = sessions.length > 0
|
||||
? sessions.reduce((latest, session) =>
|
||||
new Date(session.started_at) > new Date(latest.started_at) ? session : latest
|
||||
).started_at
|
||||
: undefined
|
||||
return {
|
||||
...tree,
|
||||
lastUsed,
|
||||
sessionCount: sessions.length,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load stats for tree ${tree.id}:`, err)
|
||||
return {
|
||||
...tree,
|
||||
sessionCount: 0,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setTrees(treesWithStats)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load your trees')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartSession = (treeId: string) => {
|
||||
navigate(`/trees/${treeId}/navigate`)
|
||||
}
|
||||
|
||||
const handleDeleteTree = async () => {
|
||||
if (!treeToDelete) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await treesApi.delete(treeToDelete.id)
|
||||
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
|
||||
toast.success(`Tree "${treeToDelete.name}" deleted successfully`)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tree:', err)
|
||||
toast.error('Failed to delete tree')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteConfirm(false)
|
||||
setTreeToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="font-heading text-3xl font-bold sm:text-4xl">
|
||||
<span className="text-gradient-brand">My Trees</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Your forked and custom decision trees
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-card/50 px-4 py-12 text-center">
|
||||
<FolderTree className="mx-auto mb-4 h-12 w-12 text-muted-foreground opacity-50" />
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">No personal trees yet</h2>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Fork a tree from the library to customize it for your workflow
|
||||
</p>
|
||||
<Link
|
||||
to="/trees"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Browse Trees
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="rounded-lg border border-border bg-card p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">{tree.name}</h3>
|
||||
{tree.category_info && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{tree.category_info.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{tree.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
{/* Fork Badge */}
|
||||
{tree.parent_tree_id && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-md bg-accent/50 px-2 py-1.5 text-sm">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Forked from{' '}
|
||||
<Link
|
||||
to={`/trees/${tree.parent_tree_id}/navigate`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
original
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tree.tags && tree.tags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<TagBadges tags={tree.tags} maxVisible={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mb-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{formatDate(tree.lastUsed)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
<span>{tree.sessionCount || 0} uses</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartSession(tree.id)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Start
|
||||
</button>
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTreeToShare(tree)
|
||||
setShowShareModal(true)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
title="Share tree"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-input p-2 text-muted-foreground',
|
||||
'hover:bg-destructive/10 hover:text-destructive'
|
||||
)}
|
||||
title="Delete tree"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false)
|
||||
setTreeToDelete(null)
|
||||
}}
|
||||
onConfirm={handleDeleteTree}
|
||||
title="Delete Tree"
|
||||
message={`Are you sure you want to delete "${treeToDelete?.name}"? This action can be undone by an administrator.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="destructive"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Share Tree Modal */}
|
||||
{treeToShare && (
|
||||
<ShareTreeModal
|
||||
tree={treeToShare}
|
||||
isOpen={showShareModal}
|
||||
onClose={() => {
|
||||
setShowShareModal(false)
|
||||
setTreeToShare(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyTreesPage
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Copy, Check, Eye } from 'lucide-react'
|
||||
import { sessionsApi } from '@/api'
|
||||
import { Copy, Check, Eye, Save } from 'lucide-react'
|
||||
import { sessionsApi, stepsApi } from '@/api'
|
||||
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
||||
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
||||
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import type { Session, SessionExport } from '@/types'
|
||||
import type { Session, SessionExport, SaveAsTreeRequest, Step } from '@/types'
|
||||
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
@@ -20,6 +23,11 @@ export function SessionDetailPage() {
|
||||
const [exportContent, setExportContent] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
|
||||
const [isSavingTree, setIsSavingTree] = useState(false)
|
||||
const [showRatingModal, setShowRatingModal] = useState(false)
|
||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -27,6 +35,36 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}, [id])
|
||||
|
||||
// Auto-show rating modal for completed sessions with library steps
|
||||
useEffect(() => {
|
||||
if (!session || !session.completed_at) return
|
||||
|
||||
// Check if already rated
|
||||
if (hasRatedSession(session.id)) return
|
||||
|
||||
// Extract library steps from custom_steps
|
||||
const stepsFromLibrary = session.custom_steps?.filter(
|
||||
(customStep) => {
|
||||
// Check if step_data is a Step (from library) by checking if it has an id
|
||||
const stepData = customStep.step_data
|
||||
return 'id' in stepData && stepData.id
|
||||
}
|
||||
) || []
|
||||
|
||||
if (stepsFromLibrary.length === 0) return
|
||||
|
||||
// Extract the Step objects
|
||||
const steps = stepsFromLibrary.map((cs) => cs.step_data as Step)
|
||||
setLibrarySteps(steps)
|
||||
|
||||
// Show modal after 1 second delay
|
||||
const timer = setTimeout(() => {
|
||||
setShowRatingModal(true)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [session])
|
||||
|
||||
const loadSession = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -104,6 +142,58 @@ export function SessionDetailPage() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
|
||||
if (!session) return
|
||||
setIsSavingTree(true)
|
||||
try {
|
||||
const result = await sessionsApi.saveAsTree(session.id, data)
|
||||
toast.success(result.message)
|
||||
setShowSaveAsTreeModal(false)
|
||||
// Navigate to tree editor with the new tree
|
||||
navigate(`/trees/${result.tree_id}/edit`)
|
||||
} catch (err) {
|
||||
console.error('Failed to save session as tree:', err)
|
||||
toast.error('Failed to save session as tree')
|
||||
} finally {
|
||||
setIsSavingTree(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultTreeName = () => {
|
||||
if (!session) return ''
|
||||
const treeName = session.tree_snapshot?.name || 'Tree'
|
||||
const ticket = session.ticket_number ? ` - ${session.ticket_number}` : ''
|
||||
return `${treeName}${ticket}`
|
||||
}
|
||||
|
||||
const handleSubmitRatings = async (ratings: Map<string, { rating: number; helpful: boolean | null; review: string }>) => {
|
||||
if (!session) return
|
||||
setIsSavingRatings(true)
|
||||
try {
|
||||
// Submit each rating individually
|
||||
const ratingPromises = Array.from(ratings.entries()).map(([stepId, data]) =>
|
||||
stepsApi.rate(stepId, {
|
||||
rating: data.rating,
|
||||
review_text: data.review || undefined,
|
||||
was_helpful: data.helpful !== null ? data.helpful : undefined,
|
||||
session_id: session.id,
|
||||
is_verified_use: true
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(ratingPromises)
|
||||
|
||||
toast.success(`Submitted ${ratings.size} rating${ratings.size > 1 ? 's' : ''}!`)
|
||||
markSessionRated(session.id)
|
||||
setShowRatingModal(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to submit ratings:', err)
|
||||
toast.error('Failed to submit ratings')
|
||||
} finally {
|
||||
setIsSavingRatings(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
@@ -166,43 +256,61 @@ export function SessionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
aria-label="Export format"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={isExporting}
|
||||
title="Copy to clipboard"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isExporting}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{isExporting ? 'Loading...' : 'Preview'}
|
||||
</button>
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* Save as Tree - Only for completed sessions */}
|
||||
{session.completed_at && (
|
||||
<button
|
||||
onClick={() => setShowSaveAsTreeModal(true)}
|
||||
disabled={isSavingTree}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Tree
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Export Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
aria-label="Export format"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={isExporting}
|
||||
title="Copy to clipboard"
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isExporting}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{isExporting ? 'Loading...' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +375,24 @@ export function SessionDetailPage() {
|
||||
format={exportFormat}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
|
||||
{/* Save as Tree Modal */}
|
||||
<SaveSessionAsTreeModal
|
||||
isOpen={showSaveAsTreeModal}
|
||||
onClose={() => setShowSaveAsTreeModal(false)}
|
||||
onSave={handleSaveAsTree}
|
||||
defaultTreeName={getDefaultTreeName()}
|
||||
isSaving={isSavingTree}
|
||||
/>
|
||||
|
||||
{/* Step Rating Modal */}
|
||||
<StepRatingModal
|
||||
isOpen={showRatingModal}
|
||||
onClose={() => setShowRatingModal(false)}
|
||||
onSubmit={handleSubmitRatings}
|
||||
librarySteps={librarySteps}
|
||||
isSaving={isSavingRatings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor } from 'lucide-react'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText } from 'lucide-react'
|
||||
import { treesApi } from '@/api'
|
||||
import type { TreeCreate, TreeUpdate } from '@/types'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
|
||||
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
|
||||
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
|
||||
@@ -41,6 +41,7 @@ export function TreeEditorPage() {
|
||||
const { undo, redo, pastStates, futureStates } = useStore(useTreeEditorTemporal)
|
||||
|
||||
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
|
||||
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
||||
|
||||
// Mobile detection
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -107,12 +108,14 @@ export function TreeEditorPage() {
|
||||
return
|
||||
}
|
||||
loadTree(tree)
|
||||
setTreeStatus(tree.status) // Load status from existing tree
|
||||
} catch (err) {
|
||||
console.error('Failed to load tree:', err)
|
||||
navigate('/trees')
|
||||
}
|
||||
} else {
|
||||
initNewTree()
|
||||
setTreeStatus('draft') // New trees start as draft
|
||||
// Check for draft after initializing
|
||||
const draftExists = localStorage.getItem('tree-editor-draft') !== null
|
||||
if (draftExists) {
|
||||
@@ -159,38 +162,76 @@ export function TreeEditorPage() {
|
||||
selectNode(nodeId)
|
||||
}
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus }
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
setTreeStatus('draft')
|
||||
markSaved()
|
||||
toast.success('Draft saved successfully')
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
setTreeStatus('draft')
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
toast.success('Draft created successfully')
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save draft:', err)
|
||||
toast.error('Failed to save draft. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isEditMode, id, getTreeForSave, markSaved, navigate])
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
// Validate first
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
if (hasErrors) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
toast.error('Please fix validation errors before publishing')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const treeData = getTreeForSave()
|
||||
const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus }
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
setTreeStatus('published')
|
||||
markSaved()
|
||||
toast.success('Tree updated successfully')
|
||||
toast.success('Tree published successfully')
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
setTreeStatus('published')
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
toast.success('Tree created successfully')
|
||||
toast.success('Tree published successfully')
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save tree:', err)
|
||||
toast.error('Failed to save tree. Please try again.')
|
||||
console.error('Failed to publish tree:', err)
|
||||
toast.error('Failed to publish tree. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
|
||||
|
||||
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
|
||||
const handleSave = useCallback(async () => {
|
||||
// If tree is already published or has no errors, publish; otherwise save as draft
|
||||
if (treeStatus === 'published' || !hasBlockingErrors) {
|
||||
await handlePublish()
|
||||
} else {
|
||||
await handleSaveDraft()
|
||||
}
|
||||
}, [treeStatus, hasBlockingErrors, handlePublish, handleSaveDraft])
|
||||
|
||||
// Handle blocker
|
||||
const handleBlockerProceed = () => {
|
||||
if (blocker.state === 'blocked') {
|
||||
@@ -314,11 +355,19 @@ export function TreeEditorPage() {
|
||||
{isEditMode ? 'Edit Tree' : 'Create New Tree'}
|
||||
{name && <span className="ml-2 text-muted-foreground">- {name}</span>}
|
||||
</h1>
|
||||
{isDirty && (
|
||||
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{treeStatus === 'draft' && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
<FileText className="h-3 w-3" />
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
{isDirty && (
|
||||
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -371,18 +420,32 @@ export function TreeEditorPage() {
|
||||
Validate
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
{/* Save Draft */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isSaving || !isDirty}
|
||||
title="Save as draft (Ctrl+S when draft or has errors)"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium',
|
||||
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Draft
|
||||
</button>
|
||||
|
||||
{/* Publish */}
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={isSaving || !isDirty || hasBlockingErrors}
|
||||
title={hasBlockingErrors ? 'Fix validation errors before saving' : undefined}
|
||||
title={hasBlockingErrors ? 'Fix validation errors before publishing (Ctrl+S when no errors)' : 'Publish tree (Ctrl+S when no errors)'}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{isSaving ? 'Publishing...' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function TreeLibraryPage() {
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showDrafts, setShowDrafts] = useState(false)
|
||||
|
||||
// View preferences from store
|
||||
const { treeLibraryView, setTreeLibraryView, treeLibrarySortBy, setTreeLibrarySortBy } =
|
||||
@@ -45,6 +46,9 @@ export function TreeLibraryPage() {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Fork state
|
||||
const [isForkingTree, setIsForkingTree] = useState(false)
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
try {
|
||||
const foldersData = await foldersApi.list()
|
||||
@@ -56,7 +60,7 @@ export function TreeLibraryPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy])
|
||||
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts])
|
||||
|
||||
// Load folders on mount and listen for changes
|
||||
useEffect(() => {
|
||||
@@ -75,6 +79,7 @@ export function TreeLibraryPage() {
|
||||
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
|
||||
folder_id: selectedFolderId || undefined,
|
||||
sort_by: treeLibrarySortBy,
|
||||
include_drafts: showDrafts || undefined,
|
||||
}),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
@@ -156,6 +161,21 @@ export function TreeLibraryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleForkTree = async (treeId: string) => {
|
||||
if (isForkingTree) return
|
||||
setIsForkingTree(true)
|
||||
try {
|
||||
await treesApi.fork(treeId)
|
||||
toast.success('Tree forked successfully')
|
||||
navigate('/my-trees')
|
||||
} catch (err) {
|
||||
console.error('Failed to fork tree:', err)
|
||||
toast.error('Failed to fork tree')
|
||||
} finally {
|
||||
setIsForkingTree(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||
|
||||
@@ -257,7 +277,18 @@ export function TreeLibraryPage() {
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<div className="flex items-center gap-4">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDrafts}
|
||||
onChange={(e) => setShowDrafts(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show my drafts</span>
|
||||
</label>
|
||||
</div>
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,6 +364,7 @@ export function TreeLibraryPage() {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'list' && (
|
||||
@@ -345,6 +377,7 @@ export function TreeLibraryPage() {
|
||||
setTreeToDelete(tree)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'table' && (
|
||||
@@ -362,6 +395,7 @@ export function TreeLibraryPage() {
|
||||
sortBy as 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export { default as LoginPage } from './LoginPage'
|
||||
export { default as RegisterPage } from './RegisterPage'
|
||||
export { default as TreeLibraryPage } from './TreeLibraryPage'
|
||||
export { default as MyTreesPage } from './MyTreesPage'
|
||||
export { default as TreeNavigationPage } from './TreeNavigationPage'
|
||||
export { default as TreeEditorPage } from './TreeEditorPage'
|
||||
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
||||
export { default as SessionDetailPage } from './SessionDetailPage'
|
||||
export { default as SettingsPage } from './SettingsPage'
|
||||
export { default as AccountSettingsPage } from './AccountSettingsPage'
|
||||
export { default as AdminCategoriesPage } from './AdminCategoriesPage'
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { AppLayout, ProtectedRoute } from '@/components/layout'
|
||||
import { RouteError } from '@/components/common/RouteError'
|
||||
import { PageLoader } from '@/components/common/PageLoader'
|
||||
import {
|
||||
LoginPage,
|
||||
RegisterPage,
|
||||
TreeLibraryPage,
|
||||
TreeNavigationPage,
|
||||
TreeEditorPage,
|
||||
SessionHistoryPage,
|
||||
SessionDetailPage,
|
||||
SettingsPage,
|
||||
AccountSettingsPage,
|
||||
} from '@/pages'
|
||||
|
||||
// Lazy load heavy pages for code splitting
|
||||
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
|
||||
const MyTreesPage = lazy(() => import('@/pages/MyTreesPage'))
|
||||
const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage'))
|
||||
const TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage'))
|
||||
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
||||
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage'))
|
||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||
const AdminCategoriesPage = lazy(() => import('@/pages/AdminCategoriesPage'))
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
@@ -39,35 +45,85 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: 'trees',
|
||||
element: <TreeLibraryPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<TreeLibraryPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'my-trees',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<MyTreesPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'trees/new',
|
||||
element: <TreeEditorPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<TreeEditorPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'trees/:id/edit',
|
||||
element: <TreeEditorPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<TreeEditorPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'trees/:id/navigate',
|
||||
element: <TreeNavigationPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<TreeNavigationPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sessions',
|
||||
element: <SessionHistoryPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SessionHistoryPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sessions/:id',
|
||||
element: <SessionDetailPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SessionDetailPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
element: <AccountSettingsPage />,
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AccountSettingsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/categories',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ProtectedRoute requiredRole="super_admin">
|
||||
<AdminCategoriesPage />
|
||||
</ProtectedRoute>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -81,3 +81,16 @@ export interface SessionNavigationState {
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// Save session as tree
|
||||
export interface SaveAsTreeRequest {
|
||||
tree_name?: string
|
||||
description?: string
|
||||
status: 'draft' | 'published'
|
||||
}
|
||||
|
||||
export interface SaveAsTreeResponse {
|
||||
tree_id: string
|
||||
tree_name: string
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -60,6 +60,28 @@ export interface StepCategory {
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface StepCategoryListItem {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface StepCategoryCreate {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface StepCategoryUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface StepCategoryListParams {
|
||||
include_inactive?: boolean
|
||||
}
|
||||
|
||||
export interface StepListParams {
|
||||
visibility?: 'private' | 'team' | 'public'
|
||||
category_id?: string
|
||||
@@ -97,7 +119,9 @@ export interface StepUpdate {
|
||||
export interface RatingCreate {
|
||||
rating: number
|
||||
review_text?: string
|
||||
verified_use: boolean
|
||||
was_helpful?: boolean
|
||||
session_id?: string
|
||||
is_verified_use?: boolean
|
||||
}
|
||||
|
||||
export interface RatingUpdate {
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface TreeStructure {
|
||||
}
|
||||
|
||||
// API response types
|
||||
export type TreeStatus = 'draft' | 'published'
|
||||
|
||||
export interface Tree {
|
||||
id: string
|
||||
name: string
|
||||
@@ -71,6 +73,7 @@ export interface Tree {
|
||||
is_active: boolean
|
||||
is_public: boolean
|
||||
is_default: boolean
|
||||
status: TreeStatus
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -90,6 +93,7 @@ export interface TreeListItem {
|
||||
is_active: boolean
|
||||
is_public: boolean
|
||||
is_default: boolean
|
||||
status: TreeStatus
|
||||
version: number
|
||||
usage_count: number
|
||||
created_at: string
|
||||
@@ -105,6 +109,7 @@ export interface TreeCreate {
|
||||
tree_structure: TreeStructure
|
||||
is_public?: boolean
|
||||
is_default?: boolean
|
||||
status?: TreeStatus
|
||||
}
|
||||
|
||||
export interface TreeUpdate {
|
||||
@@ -116,6 +121,7 @@ export interface TreeUpdate {
|
||||
tree_structure?: TreeStructure
|
||||
is_active?: boolean
|
||||
is_public?: boolean
|
||||
status?: TreeStatus
|
||||
}
|
||||
|
||||
// Filter params for tree listing
|
||||
@@ -127,7 +133,55 @@ export interface TreeFilters {
|
||||
is_active?: boolean
|
||||
author_id?: string
|
||||
is_public?: boolean
|
||||
include_drafts?: boolean
|
||||
sort_by?: 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// Tree sharing types
|
||||
export type TreeVisibility = 'private' | 'team' | 'link' | 'public'
|
||||
|
||||
export interface TreeShareCreate {
|
||||
allow_forking?: boolean
|
||||
expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface TreeShare {
|
||||
id: string
|
||||
tree_id: string
|
||||
share_token: string
|
||||
share_url: string
|
||||
allow_forking: boolean
|
||||
created_by: string
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface TreeVisibilityUpdate {
|
||||
visibility: TreeVisibility
|
||||
}
|
||||
|
||||
export interface SharedTree {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
tree_structure: TreeStructure
|
||||
tags: string[]
|
||||
version: number
|
||||
allow_forking: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Tree validation types
|
||||
export interface ValidationError {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TreeValidationResponse {
|
||||
can_publish: boolean
|
||||
errors: ValidationError[]
|
||||
}
|
||||
|
||||
@@ -10,4 +10,36 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React core and routing
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
// Markdown rendering
|
||||
'markdown-vendor': ['react-markdown'],
|
||||
// State management
|
||||
'state-vendor': ['zustand', 'immer', 'zundo'],
|
||||
// Icons
|
||||
'icons-vendor': ['lucide-react'],
|
||||
// Utilities and UI libs
|
||||
'utils-vendor': [
|
||||
'axios',
|
||||
'clsx',
|
||||
'tailwind-merge',
|
||||
'class-variance-authority',
|
||||
'date-fns',
|
||||
'sonner',
|
||||
],
|
||||
// Drag and drop
|
||||
'dnd-vendor': [
|
||||
'@dnd-kit/core',
|
||||
'@dnd-kit/sortable',
|
||||
'@dnd-kit/utilities',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user