diff --git a/crawler_platform.db b/crawler_platform.db deleted file mode 100644 index d10a1bb..0000000 Binary files a/crawler_platform.db and /dev/null differ diff --git a/ontology_platform/web/frontend/index.html b/ontology_platform/web/frontend/index.html index 414f057..77ad86f 100644 --- a/ontology_platform/web/frontend/index.html +++ b/ontology_platform/web/frontend/index.html @@ -4,7 +4,40 @@ - Ontology Builder - AI-Powered Ontology Construction + + Ontology Builder — AI-Powered Ontology Construction + + + + + + + + + +
diff --git a/ontology_platform/web/frontend/package-lock.json b/ontology_platform/web/frontend/package-lock.json index 53a1d7e..9909e06 100644 --- a/ontology_platform/web/frontend/package-lock.json +++ b/ontology_platform/web/frontend/package-lock.json @@ -9,8 +9,14 @@ "version": "0.2.0", "dependencies": { "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^1.9.7", "@tanstack/react-query": "^5.28.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cytoscape": "^3.33.3", @@ -786,6 +792,44 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -883,6 +927,669 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -1440,6 +2147,66 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1857,6 +2624,18 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -2240,6 +3019,12 @@ } } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2443,6 +3228,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3180,6 +3974,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -3212,6 +4053,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reactflow": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", @@ -3581,6 +4444,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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3626,6 +4495,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/ontology_platform/web/frontend/package.json b/ontology_platform/web/frontend/package.json index 5bb09e9..955e2db 100644 --- a/ontology_platform/web/frontend/package.json +++ b/ontology_platform/web/frontend/package.json @@ -21,8 +21,14 @@ }, "dependencies": { "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^1.9.7", "@tanstack/react-query": "^5.28.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cytoscape": "^3.33.3", diff --git a/ontology_platform/web/frontend/public/locales/en/common.json b/ontology_platform/web/frontend/public/locales/en/common.json index 7e7fa28..0c1416e 100644 --- a/ontology_platform/web/frontend/public/locales/en/common.json +++ b/ontology_platform/web/frontend/public/locales/en/common.json @@ -6,12 +6,36 @@ "nav": { "dashboard": "Dashboard", "onboard": "New Project", - "sources": "Sources", - "crawl": "Crawl", - "research": "Research", - "editor": "Editor", + "sources": "Source Explorer", + "crawl": "Seed Crawl", + "pipeline": "Build Pipeline", + "analysis": "Page Analysis", + "schema": "Schema Designer", + "research": "Graph Research", + "editor": "Entity Manager", + "review": "Claim Review", + "quality": "Quality Inspector", + "graph": "Graph View", + "export": "Export / API", + "toggleSidebar": "Toggle sidebar", + "openCommandPalette": "Open command palette", + "searchPlaceholder": "Search pages, projects…", + "help": "Help", + "account": "Account", + "projects": "Projects", + "noProjects": "No projects yet", + "noProjectSelected": "Select project", + "selectProjectFirst": "Select a project from the dashboard first" + }, + "navGroup": { + "workspace": "Workspace", + "build": "Build", "review": "Review", - "toggleSidebar": "Toggle sidebar" + "publish": "Publish" + }, + "theme": { + "label": "Theme", + "toggle": "Switch theme" }, "dashboard": { "title": "Ontology Builder", diff --git a/ontology_platform/web/frontend/public/locales/ko/common.json b/ontology_platform/web/frontend/public/locales/ko/common.json index 5e698b7..5e5842e 100644 --- a/ontology_platform/web/frontend/public/locales/ko/common.json +++ b/ontology_platform/web/frontend/public/locales/ko/common.json @@ -8,10 +8,34 @@ "onboard": "프로젝트 생성", "sources": "참고 소스", "crawl": "크롤 진행", + "pipeline": "빌드 파이프라인", + "analysis": "페이지 분석", + "schema": "스키마 디자이너", "research": "자율 연구", "editor": "온톨로지 편집", "review": "결과 검토", - "toggleSidebar": "사이드바 토글" + "quality": "품질 검사", + "graph": "그래프 보기", + "export": "내보내기 / API", + "toggleSidebar": "사이드바 토글", + "openCommandPalette": "커맨드 팔레트 열기", + "searchPlaceholder": "페이지·프로젝트 검색…", + "help": "도움말", + "account": "계정", + "projects": "프로젝트", + "noProjects": "등록된 프로젝트가 없습니다", + "noProjectSelected": "프로젝트 선택", + "selectProjectFirst": "먼저 대시보드에서 프로젝트를 선택하세요" + }, + "navGroup": { + "workspace": "워크스페이스", + "build": "구축", + "review": "검토", + "publish": "배포" + }, + "theme": { + "label": "테마", + "toggle": "테마 전환" }, "dashboard": { "title": "온톨로지 빌더", diff --git a/ontology_platform/web/frontend/src/components/ErrorBoundary.tsx b/ontology_platform/web/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..691950b --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import { ErrorState } from "@/components/ui/error-state"; + +interface ErrorBoundaryState { + error: Error | null; +} + +interface ErrorBoundaryProps { + children: React.ReactNode; + /** Render prop for full custom fallback */ + fallback?: (error: Error, reset: () => void) => React.ReactNode; + /** Reset boundary when any value in this array changes (like `useEffect` deps) */ + resetKeys?: unknown[]; + /** Called once when a render error is caught */ + onError?: (error: Error, info: React.ErrorInfo) => void; +} + +/** + * Catches render-time errors anywhere below it and shows a fallback UI. + * Wrap the whole app at the root (catches everything) and optionally + * wrap individual routes/sections for finer-grained recovery. + * + * Note: does NOT catch errors inside event handlers, async code, or + * server-side rendering. For those, surface errors via React Query + * `onError` or `toast.error()`. + */ +export class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + this.props.onError?.(error, info); + // eslint-disable-next-line no-console + console.error("[ErrorBoundary]", error, info); + } + + componentDidUpdate(prevProps: ErrorBoundaryProps) { + if (!this.state.error) return; + const prev = prevProps.resetKeys ?? []; + const next = this.props.resetKeys ?? []; + if ( + prev.length !== next.length || + prev.some((v, i) => !Object.is(v, next[i])) + ) { + this.reset(); + } + } + + reset = () => this.setState({ error: null }); + + render() { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.fallback) return this.props.fallback(error, this.reset); + + return ( +
+ +
+ ); + } +} diff --git a/ontology_platform/web/frontend/src/components/layout/AppShell.tsx b/ontology_platform/web/frontend/src/components/layout/AppShell.tsx index dec7d2a..5d7d5f5 100644 --- a/ontology_platform/web/frontend/src/components/layout/AppShell.tsx +++ b/ontology_platform/web/frontend/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { matchPath, NavLink, @@ -7,69 +7,39 @@ import { useNavigate, } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { useSelector, useDispatch } from "react-redux"; import { - LayoutDashboard, - UploadCloud, - Settings2, - Activity, - Brain, - Code2, - GitBranch, - Network, - ListChecks, - Menu, - SearchCheck, - ShieldCheck, - ClipboardCheck, - Layers3, + ChevronDown, + ChevronsLeft, + ChevronsRight, + Command, + HelpCircle, + Search, + Sparkles, } from "lucide-react"; import { RootState } from "@/stores"; import { toggleSidebar } from "@/stores/slices/uiSlice"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useProjects } from "@/hooks/useProjects"; - -interface NavItem { - to?: string; - projectPath?: string; - labelKey: string; - label: string; - icon: React.ComponentType<{ className?: string }>; -} - -const navItems: NavItem[] = [ - { to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard }, - { to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud }, - { projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 }, - { projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity }, - { projectPath: "pipeline", labelKey: "nav.pipeline", label: "Build Pipeline", icon: Layers3 }, - { projectPath: "analysis", labelKey: "nav.analysis", label: "Page Analysis", icon: SearchCheck }, - { projectPath: "schema", labelKey: "nav.schema", label: "Schema Designer", icon: ShieldCheck }, - { projectPath: "editor", labelKey: "nav.editor", label: "Entity Manager", icon: Network }, - { projectPath: "review", labelKey: "nav.review", label: "Claim Review", icon: ListChecks }, - { projectPath: "quality", labelKey: "nav.quality", label: "Quality Inspector", icon: ClipboardCheck }, - { projectPath: "graph", labelKey: "nav.graph", label: "Graph View", icon: GitBranch }, - { projectPath: "export", labelKey: "nav.export", label: "Export / API", icon: Code2 }, - { projectPath: "research", labelKey: "nav.research", label: "Graph Research", icon: Brain }, -]; - -const projectRoutePatterns = [ - "/sources/:projectId", - "/crawl/:projectId", - "/pipeline/:projectId", - "/analysis/:projectId", - "/schema/:projectId", - "/research/:projectId", - "/editor/:projectId", - "/review/:projectId", - "/quality/:projectId", - "/graph/:projectId", - "/export/:projectId", -]; +import { useThemeSync } from "@/hooks/useTheme"; +import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; +import { ThemeToggle } from "@/components/layout/ThemeToggle"; +import { + ShortcutsOverlay, + type ShortcutGroup, +} from "@/components/ui/shortcuts-overlay"; +import { Tooltip } from "@/components/ui/tooltip"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { NAV_GROUPS, PROJECT_ROUTE_PATTERNS, type NavItem } from "./navigation"; +import { + CommandPalette, + useCommandPaletteShortcut, +} from "./CommandPalette"; function projectIdFromPathname(pathname: string): string | undefined { - for (const pattern of projectRoutePatterns) { + for (const pattern of PROJECT_ROUTE_PATTERNS) { const match = matchPath({ path: pattern, end: false }, pathname); if (match?.params.projectId) return match.params.projectId; } @@ -77,7 +47,7 @@ function projectIdFromPathname(pathname: string): string | undefined { } function workspaceSectionFromPathname(pathname: string): string | undefined { - for (const pattern of projectRoutePatterns) { + for (const pattern of PROJECT_ROUTE_PATTERNS) { const match = matchPath({ path: pattern, end: false }, pathname); if (match?.params.projectId) return pattern.split("/")[1]; } @@ -85,11 +55,17 @@ function workspaceSectionFromPathname(pathname: string): string | undefined { } export default function AppShell() { + useThemeSync(); const { t } = useTranslation(); const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen); const dispatch = useDispatch(); const location = useLocation(); const navigate = useNavigate(); + const [paletteOpen, setPaletteOpen] = useState(false); + const [projectMenuOpen, setProjectMenuOpen] = useState(false); + const [chordLeader, setChordLeader] = useState(null); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const routeProjectId = projectIdFromPathname(location.pathname); const workspaceSection = workspaceSectionFromPathname(location.pathname); const { data: projects } = useProjects(); @@ -99,9 +75,58 @@ export default function AppShell() { !projects || projects.some((project) => project.name === routeProjectId); const currentProjectId = routeProjectExists - ? routeProjectId ?? fallbackProjectId + ? (routeProjectId ?? fallbackProjectId) : fallbackProjectId; + useCommandPaletteShortcut(() => setPaletteOpen((v) => !v)); + + // Build chord bindings from NAV_GROUPS (e.g. "g d" -> /, "g s" -> /sources/...). + const shortcutBindings = useMemo(() => { + const bindings: Record void> = {}; + const collectItem = (item: NavItem) => { + if (!item.shortcut) return; + const href = + item.to ?? + (item.projectPath && currentProjectId + ? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}` + : undefined); + if (!href) return; + bindings[item.shortcut] = () => navigate(href); + }; + NAV_GROUPS.forEach((g) => g.items.forEach(collectItem)); + bindings["/"] = () => setPaletteOpen(true); + bindings["Shift+?"] = () => setShortcutsOpen(true); + return bindings; + }, [currentProjectId, navigate]); + + useKeyboardShortcuts(shortcutBindings, { + disabled: paletteOpen || shortcutsOpen, + onChordStart: (leader) => setChordLeader(leader), + onChordEnd: () => setChordLeader(null), + }); + + // Build groups for the ShortcutsOverlay (memoized) + const shortcutGroups = useMemo(() => { + const navGroups: ShortcutGroup[] = NAV_GROUPS.map((g) => ({ + title: g.label, + items: g.items + .filter((i) => i.shortcut) + .map((i) => ({ keys: i.shortcut!, label: i.label })), + })).filter((g) => g.items.length > 0); + return [ + ...navGroups, + { + title: "Global", + items: [ + { keys: "Mod+K", label: "Open command palette" }, + { keys: "/", label: "Open command palette" }, + { keys: "Shift+?", label: "Show this shortcuts panel" }, + { keys: "Escape", label: "Close any open dialog" }, + ], + }, + ]; + }, []); + useEffect(() => { if (routeProjectId && projects && !routeProjectExists) { const nextPath = @@ -119,98 +144,362 @@ export default function AppShell() { workspaceSection, ]); + const switchProject = (projectName: string) => { + setProjectMenuOpen(false); + const section = workspaceSection ?? "sources"; + navigate(`/${section}/${encodeURIComponent(projectName)}`); + }; + + // Detect macOS to show ⌘ vs Ctrl + const isMac = + typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const modKey = isMac ? "⌘" : "Ctrl"; + return (
- - -
-
-

- {t("app.subtitle", "AI-powered ontology construction")} -

+ + + + +
+ + ON + +
-
- + +
+ + +
+ + + + + + +
+ ); +} + +function ChordHint({ leader }: { leader: string | null }) { + if (!leader) return null; + return ( +
+
+ + {leader} + + + awaiting follower key +
+
+ ); +} + +/* ============================================================ + * Sidebar + * ========================================================== */ + +function Sidebar({ + sidebarOpen, + currentProjectId, + onToggle, + t, +}: { + sidebarOpen: boolean; + currentProjectId: string | null; + onToggle: () => void; + t: TFunction; +}) { + return ( + + ); +} + +/* ============================================================ + * Project Switcher (header dropdown) + * ========================================================== */ + +function ProjectSwitcher({ + projects, + currentProjectId, + open, + onOpenChange, + onSelect, + t, +}: { + projects: string[]; + currentProjectId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (name: string) => void; + t: TFunction; +}) { + const ref = useMemo(() => ({ current: null as HTMLDivElement | null }), []); + + useEffect(() => { + if (!open) return; + const onClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + onOpenChange(false); + } + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [open, onOpenChange, ref]); + + return ( +
(ref.current = node)}> + + {open && ( +
+
+ {t("nav.projects", "Projects")} +
+
    + {projects.length === 0 && ( +
  • + {t("nav.noProjects", "No projects yet.")} +
  • + )} + {projects.map((name) => ( +
  • + +
  • + ))} +
+
+ + + Press K for palette + +
+
+ )}
); } diff --git a/ontology_platform/web/frontend/src/components/layout/CommandPalette.tsx b/ontology_platform/web/frontend/src/components/layout/CommandPalette.tsx new file mode 100644 index 0000000..a069ba1 --- /dev/null +++ b/ontology_platform/web/frontend/src/components/layout/CommandPalette.tsx @@ -0,0 +1,291 @@ +import * as React from "react"; +import { useNavigate } from "react-router-dom"; +import { Search, FolderKanban, Sun, Moon, Monitor, CornerDownLeft } from "lucide-react"; +import { useDispatch, useSelector } from "react-redux"; +import { Dialog, DialogPanel } from "@/components/ui/dialog"; +import { useProjects } from "@/hooks/useProjects"; +import { NAV_GROUPS, type NavItem } from "./navigation"; +import { cn } from "@/lib/utils"; +import { cycleTheme, setTheme } from "@/stores/slices/uiSlice"; +import type { RootState } from "@/stores"; + +type CommandItem = { + id: string; + group: string; + label: string; + hint?: string; + keywords?: string[]; + icon: React.ComponentType<{ className?: string }>; + onSelect: () => void; +}; + +function score(query: string, item: CommandItem): number { + if (!query) return 1; + const q = query.toLowerCase(); + const haystack = [item.label, item.group, ...(item.keywords ?? [])] + .join(" ") + .toLowerCase(); + if (haystack.includes(q)) return 2; + // fuzzy: every char in q appears in order + let pos = 0; + for (const ch of q) { + pos = haystack.indexOf(ch, pos); + if (pos === -1) return 0; + pos++; + } + return 1; +} + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Current project to use for project-scoped nav items */ + currentProjectId?: string | null; +} + +export function CommandPalette({ + open, + onOpenChange, + currentProjectId, +}: CommandPaletteProps) { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const theme = useSelector((s: RootState) => s.ui.theme); + const { data: projects } = useProjects(); + const [query, setQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(0); + const listRef = React.useRef(null); + + const navHref = (item: NavItem): string | undefined => { + if (item.to) return item.to; + if (item.projectPath && currentProjectId) { + return `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`; + } + return undefined; + }; + + const close = React.useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + const commands = React.useMemo(() => { + const list: CommandItem[] = []; + + NAV_GROUPS.forEach((group) => { + group.items.forEach((item) => { + const href = navHref(item); + list.push({ + id: `nav:${item.id}`, + group: group.label, + label: item.label, + hint: item.shortcut, + keywords: item.keywords, + icon: item.icon, + onSelect: () => { + if (href) navigate(href); + close(); + }, + }); + }); + }); + + (projects ?? []).slice(0, 12).forEach((project) => { + list.push({ + id: `project:${project.name}`, + group: "Projects", + label: project.name, + keywords: ["switch", "open", "project"], + icon: FolderKanban, + onSelect: () => { + navigate(`/sources/${encodeURIComponent(project.name)}`); + close(); + }, + }); + }); + + const themeOpts: Array<{ + mode: "light" | "dark" | "system"; + label: string; + icon: React.ComponentType<{ className?: string }>; + }> = [ + { mode: "light", label: "Switch to Light theme", icon: Sun }, + { mode: "dark", label: "Switch to Dark theme", icon: Moon }, + { mode: "system", label: "Use System theme", icon: Monitor }, + ]; + themeOpts.forEach((opt) => { + list.push({ + id: `theme:${opt.mode}`, + group: "Settings", + label: opt.label, + keywords: ["theme", "dark", "light", "mode", opt.mode], + icon: opt.icon, + onSelect: () => { + dispatch(setTheme(opt.mode)); + close(); + }, + }); + }); + list.push({ + id: "theme:cycle", + group: "Settings", + label: `Cycle theme (current: ${theme})`, + keywords: ["theme", "toggle"], + icon: Sun, + onSelect: () => { + dispatch(cycleTheme()); + close(); + }, + }); + + return list; + }, [projects, navigate, currentProjectId, dispatch, close, theme]); + + const filtered = React.useMemo(() => { + const scored = commands + .map((cmd) => ({ cmd, s: score(query, cmd) })) + .filter((x) => x.s > 0); + scored.sort((a, b) => b.s - a.s); + return scored.map((x) => x.cmd); + }, [commands, query]); + + React.useEffect(() => { + if (open) { + setQuery(""); + setActiveIndex(0); + } + }, [open]); + + React.useEffect(() => { + setActiveIndex(0); + }, [query]); + + React.useEffect(() => { + const el = listRef.current?.querySelector( + `[data-cmd-index="${activeIndex}"]`, + ); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + filtered[activeIndex]?.onSelect(); + } + }; + + // Group by section preserving filtered order + const grouped: Array<{ group: string; items: CommandItem[] }> = []; + filtered.forEach((cmd) => { + const last = grouped[grouped.length - 1]; + if (last && last.group === cmd.group) last.items.push(cmd); + else grouped.push({ group: cmd.group, items: [cmd] }); + }); + + let runningIndex = -1; + + return ( + + +
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Search pages, projects, settings…" + aria-label="Command palette input" + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + + ESC + +
+ +
+ {filtered.length === 0 && ( +

+ No results for “{query}” +

+ )} + {grouped.map((section) => ( +
+
+ {section.group} +
+ {section.items.map((cmd) => { + runningIndex += 1; + const isActive = runningIndex === activeIndex; + const Icon = cmd.icon; + return ( + + ); + })} +
+ ))} +
+ +
+
+ + + + navigate + + + + select + +
+ {filtered.length} results +
+
+
+ ); +} + +/** + * Global hook — binds ⌘K / Ctrl+K to toggle the palette. + */ +export function useCommandPaletteShortcut(onToggle: () => void) { + React.useEffect(() => { + const handler = (event: KeyboardEvent) => { + const isMod = event.metaKey || event.ctrlKey; + if (isMod && event.key.toLowerCase() === "k") { + event.preventDefault(); + onToggle(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onToggle]); +} diff --git a/ontology_platform/web/frontend/src/components/layout/PageHeader.tsx b/ontology_platform/web/frontend/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000..87b85ca --- /dev/null +++ b/ontology_platform/web/frontend/src/components/layout/PageHeader.tsx @@ -0,0 +1,129 @@ +import * as React from "react"; +import { Link } from "react-router-dom"; +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface BreadcrumbItem { + label: React.ReactNode; + to?: string; +} + +interface PageHeaderProps { + /** Optional eyebrow / breadcrumb trail. The last item is rendered as plain text. */ + breadcrumbs?: BreadcrumbItem[]; + title: React.ReactNode; + /** Optional icon shown left of the title */ + icon?: React.ComponentType<{ className?: string }>; + description?: React.ReactNode; + /** Pills/badges rendered next to the title (e.g. status, environment) */ + meta?: React.ReactNode; + /** Buttons / dropdowns on the right */ + actions?: React.ReactNode; + /** Secondary nav (tabs) row */ + tabs?: React.ReactNode; + className?: string; +} + +export function PageHeader({ + breadcrumbs, + title, + icon: Icon, + description, + meta, + actions, + tabs, + className, +}: PageHeaderProps) { + return ( +
+
+ {breadcrumbs && breadcrumbs.length > 0 && ( + + )} + +
+
+
+ {Icon && ( + + + + )} +

+ {title} +

+ {meta &&
{meta}
} +
+ {description && ( +

+ {description} +

+ )} +
+ {actions && ( +
+ {actions} +
+ )} +
+
+ + {tabs && ( +
+
{tabs}
+
+ )} +
+ ); +} + +export function Breadcrumbs({ + items, + className, +}: { + items: BreadcrumbItem[]; + className?: string; +}) { + return ( + + ); +} diff --git a/ontology_platform/web/frontend/src/components/layout/ThemeToggle.tsx b/ontology_platform/web/frontend/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..8652eed --- /dev/null +++ b/ontology_platform/web/frontend/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,37 @@ +import { useDispatch, useSelector } from "react-redux"; +import { Moon, Sun, Monitor } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { cycleTheme, type ThemeMode } from "@/stores/slices/uiSlice"; +import type { RootState } from "@/stores"; + +const ICONS: Record> = { + light: Sun, + dark: Moon, + system: Monitor, +}; + +const LABELS: Record = { + light: "Light", + dark: "Dark", + system: "System", +}; + +export function ThemeToggle() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const mode = useSelector((s: RootState) => s.ui.theme); + const Icon = ICONS[mode]; + + return ( + + ); +} diff --git a/ontology_platform/web/frontend/src/components/layout/navigation.ts b/ontology_platform/web/frontend/src/components/layout/navigation.ts new file mode 100644 index 0000000..f1e6ada --- /dev/null +++ b/ontology_platform/web/frontend/src/components/layout/navigation.ts @@ -0,0 +1,195 @@ +import type { ComponentType } from "react"; +import { + Activity, + Brain, + ClipboardCheck, + Code2, + GitBranch, + Layers3, + LayoutDashboard, + ListChecks, + Network, + SearchCheck, + Settings2, + ShieldCheck, + UploadCloud, +} from "lucide-react"; + +export type NavGroupId = "workspace" | "build" | "review" | "publish"; + +export interface NavItem { + /** Stable identifier — used as React key and palette command id */ + id: string; + /** i18n key */ + labelKey: string; + /** Default label (English) */ + label: string; + /** Static route — mutually exclusive with projectPath */ + to?: string; + /** Path segment under /:projectId — joined as `/${projectPath}/${projectId}` */ + projectPath?: string; + icon: ComponentType<{ className?: string }>; + /** Search keywords for the command palette (in addition to label) */ + keywords?: string[]; + /** Optional shortcut hint shown next to the item */ + shortcut?: string; +} + +export interface NavGroup { + id: NavGroupId; + labelKey: string; + label: string; + items: NavItem[]; +} + +export const NAV_GROUPS: NavGroup[] = [ + { + id: "workspace", + labelKey: "navGroup.workspace", + label: "Workspace", + items: [ + { + id: "dashboard", + to: "/", + labelKey: "nav.dashboard", + label: "Dashboard", + icon: LayoutDashboard, + keywords: ["home", "overview"], + shortcut: "g d", + }, + { + id: "onboard", + to: "/onboard", + labelKey: "nav.onboard", + label: "New Project", + icon: UploadCloud, + keywords: ["create", "start", "wizard"], + shortcut: "g n", + }, + ], + }, + { + id: "build", + labelKey: "navGroup.build", + label: "Build", + items: [ + { + id: "sources", + projectPath: "sources", + labelKey: "nav.sources", + label: "Source Explorer", + icon: Settings2, + keywords: ["urls", "domains", "seed"], + shortcut: "g s", + }, + { + id: "crawl", + projectPath: "crawl", + labelKey: "nav.crawl", + label: "Seed Crawl", + icon: Activity, + keywords: ["scrape", "fetch"], + shortcut: "g c", + }, + { + id: "pipeline", + projectPath: "pipeline", + labelKey: "nav.pipeline", + label: "Build Pipeline", + icon: Layers3, + keywords: ["jobs", "run", "extract"], + shortcut: "g p", + }, + { + id: "analysis", + projectPath: "analysis", + labelKey: "nav.analysis", + label: "Page Analysis", + icon: SearchCheck, + keywords: ["html", "structure"], + shortcut: "g a", + }, + { + id: "schema", + projectPath: "schema", + labelKey: "nav.schema", + label: "Schema Designer", + icon: ShieldCheck, + keywords: ["types", "predicate", "model"], + shortcut: "g t", + }, + { + id: "research", + projectPath: "research", + labelKey: "nav.research", + label: "Graph Research", + icon: Brain, + keywords: ["graphrag", "query", "ai", "autonomous"], + shortcut: "g r", + }, + ], + }, + { + id: "review", + labelKey: "navGroup.review", + label: "Review", + items: [ + { + id: "editor", + projectPath: "editor", + labelKey: "nav.editor", + label: "Entity Manager", + icon: Network, + keywords: ["entities", "nodes", "merge"], + shortcut: "g e", + }, + { + id: "review", + projectPath: "review", + labelKey: "nav.review", + label: "Claim Review", + icon: ListChecks, + keywords: ["validate", "approve", "queue"], + shortcut: "g v", + }, + { + id: "quality", + projectPath: "quality", + labelKey: "nav.quality", + label: "Quality Inspector", + icon: ClipboardCheck, + keywords: ["metrics", "qa", "audit"], + shortcut: "g q", + }, + { + id: "graph", + projectPath: "graph", + labelKey: "nav.graph", + label: "Graph View", + icon: GitBranch, + keywords: ["visualization", "network"], + shortcut: "g g", + }, + ], + }, + { + id: "publish", + labelKey: "navGroup.publish", + label: "Publish", + items: [ + { + id: "export", + projectPath: "export", + labelKey: "nav.export", + label: "Export / API", + icon: Code2, + keywords: ["download", "rest", "json"], + shortcut: "g x", + }, + ], + }, +]; + +export const PROJECT_ROUTE_PATTERNS = NAV_GROUPS.flatMap((g) => + g.items.filter((i) => i.projectPath).map((i) => `/${i.projectPath}/:projectId`), +); diff --git a/ontology_platform/web/frontend/src/components/ui/data-table.tsx b/ontology_platform/web/frontend/src/components/ui/data-table.tsx new file mode 100644 index 0000000..524a6ea --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/data-table.tsx @@ -0,0 +1,1054 @@ +import * as React from "react"; +import { + ColumnDef, + ColumnFiltersState, + ColumnOrderState, + ColumnPinningState, + ColumnSizingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + Row, + SortingState, + Table as ReactTable, + useReactTable, + VisibilityState, +} from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ChevronsUpDown, + ChevronUp, + Columns, + Inbox, + Pin, + PinOff, + Save, + Trash2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "./button"; +import { EmptyState } from "./empty-state"; +import { LoadingState } from "./loading-state"; + +/* ============================================================================ + * DataTable v2 — TanStack Table + react-virtual + Radix dropdowns + * + * Features: + * - Virtualized rows (auto-enables above `virtualizeAbove` row threshold) + * - Sortable columns (asc → desc → unsorted) + * - Column resize (drag handle) + * - Column visibility toggle (DropdownMenu) + * - Column pinning (left/right/unpin) via per-column dropdown + * - Pagination footer (optional) + * - Saved views (localStorage-backed) — keyed by `tableId` + * + * Re-export ColumnDef from @tanstack/react-table so consumers can type their + * columns with full IntelliSense. + * ========================================================================== */ + +export type { ColumnDef } from "@tanstack/react-table"; + +export type Density = "compact" | "cozy" | "comfortable"; + +const ROW_HEIGHT: Record = { + compact: 32, + cozy: 40, + comfortable: 48, +}; +const CELL_PAD: Record = { + compact: "px-3 py-1.5", + cozy: "px-3 py-2", + comfortable: "px-4 py-3", +}; + +/* ============================================================ + * Saved views — localStorage persistence + * ========================================================== */ + +interface PersistedState { + columnSizing: ColumnSizingState; + columnVisibility: VisibilityState; + columnPinning: ColumnPinningState; + columnOrder: ColumnOrderState; + sorting: SortingState; +} + +interface SavedView { + id: string; + name: string; + state: PersistedState; +} + +interface SavedViewsConfig { + views: SavedView[]; + activeViewId: string | null; +} + +const viewsStorageKey = (tableId: string) => `data-table:${tableId}:views`; +const stateStorageKey = (tableId: string) => `data-table:${tableId}:state`; + +function readJSON(key: string): T | null { + if (typeof window === "undefined") return null; + try { + const v = window.localStorage.getItem(key); + return v ? (JSON.parse(v) as T) : null; + } catch { + return null; + } +} +function writeJSON(key: string, value: unknown) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* ignore quota */ + } +} + +/* ============================================================ + * Main component + * ========================================================== */ + +export interface DataTableProps { + /** Unique id used to scope localStorage (saved views, column widths). */ + tableId: string; + data: T[] | undefined; + columns: ColumnDef[]; + loading?: boolean; + loadingRows?: number; + /** Loading variant — defaults to "table" */ + loadingVariant?: "table" | "spinner"; + /** Row stable id getter (default uses row index) */ + getRowId?: (row: T, index: number) => string; + density?: Density; + onDensityChange?: (density: Density) => void; + onRowClick?: (row: T, index: number) => void; + /** Custom empty state */ + empty?: React.ReactNode; + /** Error state node (overrides empty when present) */ + error?: React.ReactNode; + /** Estimated container height — needed for virtualization. */ + height?: number | string; + /** Row count over which virtualization auto-enables. Default 50. */ + virtualizeAbove?: number; + /** Force virtualization on/off (overrides threshold) */ + virtualize?: boolean; + /** Enable pagination footer */ + enablePagination?: boolean; + pageSize?: number; + pageSizeOptions?: number[]; + /** + * Server-side pagination — when true, the table will NOT slice rows itself. + * Parent owns `pagination` state and supplies a fresh `data` page on change. + * Must also pass `pageCount` (total pages from API) or `rowCount` (total rows). + */ + manualPagination?: boolean; + pageCount?: number; + rowCount?: number; + /** Controlled pagination state — required when manualPagination is true */ + paginationState?: PaginationState; + onPaginationStateChange?: (state: PaginationState) => void; + /** Column features (default all true) */ + enableColumnResize?: boolean; + enableColumnVisibility?: boolean; + enableColumnPinning?: boolean; + /** Enable saved views UI */ + enableSavedViews?: boolean; + /** Show density toggle in toolbar */ + enableDensityToggle?: boolean; + /** Custom toolbar nodes rendered on the left */ + toolbarStart?: React.ReactNode; + /** Custom toolbar nodes rendered on the right (before built-ins) */ + toolbarEnd?: React.ReactNode; + /** Row tone — applies a soft background tint */ + rowToneAccessor?: ( + row: T, + ) => "success" | "warning" | "danger" | "info" | undefined; + className?: string; +} + +const TONE_BG: Record<"success" | "warning" | "danger" | "info", string> = { + success: "bg-success-subtle/40", + warning: "bg-warning-subtle/40", + danger: "bg-danger-subtle/40", + info: "bg-info-subtle/40", +}; + +export function DataTable({ + tableId, + data, + columns, + loading, + loadingRows = 8, + loadingVariant = "table", + getRowId, + density: densityProp, + onDensityChange, + onRowClick, + empty, + error, + height = 520, + virtualizeAbove = 50, + virtualize, + enablePagination = false, + pageSize: pageSizeProp = 50, + pageSizeOptions = [25, 50, 100, 200], + manualPagination = false, + pageCount, + rowCount, + paginationState, + onPaginationStateChange, + enableColumnResize = true, + enableColumnVisibility = true, + enableColumnPinning = true, + enableSavedViews = true, + enableDensityToggle = true, + toolbarStart, + toolbarEnd, + rowToneAccessor, + className, +}: DataTableProps) { + /* ----------------------- Persisted state ----------------------- */ + + const persisted = React.useMemo>( + () => readJSON(stateStorageKey(tableId)) ?? {}, + [tableId], + ); + + const [sorting, setSorting] = React.useState( + persisted.sorting ?? [], + ); + const [columnVisibility, setColumnVisibility] = React.useState( + persisted.columnVisibility ?? {}, + ); + const [columnPinning, setColumnPinning] = React.useState( + persisted.columnPinning ?? { left: [], right: [] }, + ); + const [columnSizing, setColumnSizing] = React.useState( + persisted.columnSizing ?? {}, + ); + const [columnOrder, setColumnOrder] = React.useState( + persisted.columnOrder ?? [], + ); + const [columnFilters, setColumnFilters] = React.useState( + [], + ); + const [internalPagination, setInternalPagination] = React.useState({ + pageIndex: 0, + pageSize: pageSizeProp, + }); + // Controlled when caller passes paginationState (typical for server-side) + const pagination = paginationState ?? internalPagination; + const setPagination = ( + updater: PaginationState | ((prev: PaginationState) => PaginationState), + ) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + if (onPaginationStateChange) onPaginationStateChange(next); + else setInternalPagination(next); + }; + + // Uncontrolled internal density (when no parent control) + const [internalDensity, setInternalDensity] = React.useState( + densityProp ?? "cozy", + ); + const density = densityProp ?? internalDensity; + const setDensity = (d: Density) => { + if (onDensityChange) onDensityChange(d); + else setInternalDensity(d); + }; + + /* ----------------------- Persist on change ----------------------- */ + React.useEffect(() => { + writeJSON(stateStorageKey(tableId), { + sorting, + columnVisibility, + columnPinning, + columnSizing, + columnOrder, + }); + }, [ + tableId, + sorting, + columnVisibility, + columnPinning, + columnSizing, + columnOrder, + ]); + + /* ----------------------- Table instance ----------------------- */ + + const table = useReactTable({ + data: data ?? [], + columns, + state: { + sorting, + columnVisibility, + columnPinning, + columnSizing, + columnOrder, + columnFilters, + pagination: enablePagination ? pagination : undefined, + }, + getRowId, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onColumnPinningChange: setColumnPinning, + onColumnSizingChange: setColumnSizing, + onColumnOrderChange: setColumnOrder, + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + columnResizeMode: "onChange", + enableColumnResizing: enableColumnResize, + enableHiding: enableColumnVisibility, + enablePinning: enableColumnPinning, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: + enablePagination && !manualPagination + ? getPaginationRowModel() + : undefined, + manualPagination, + pageCount: + manualPagination && typeof pageCount === "number" ? pageCount : undefined, + rowCount: + manualPagination && typeof rowCount === "number" ? rowCount : undefined, + }); + + const allRows = table.getRowModel().rows; + const usingPaginationView = enablePagination && !manualPagination; + const shouldVirtualize = + virtualize ?? (!usingPaginationView && allRows.length > virtualizeAbove); + + /* ----------------------- Layout helpers ----------------------- */ + + const cellPadClass = CELL_PAD[density]; + const rowH = ROW_HEIGHT[density]; + + /* ----------------------- Render ----------------------- */ + + return ( +
+ + +
+ {error ? ( +
{error}
+ ) : loading ? ( +
+ +
+ ) : allRows.length === 0 ? ( + empty ?? ( + + ) + ) : ( + + )} +
+ + {enablePagination && !loading && allRows.length > 0 && ( + + )} +
+ ); +} + +/* ============================================================ + * Toolbar — saved views + column visibility + density + * ========================================================== */ + +function Toolbar({ + table, + tableId, + density, + setDensity, + enableSavedViews, + enableColumnVisibility, + enableDensityToggle, + toolbarStart, + toolbarEnd, + sorting, + setSorting, + columnVisibility, + setColumnVisibility, + columnPinning, + setColumnPinning, + columnSizing, + setColumnSizing, + columnOrder, + setColumnOrder, +}: { + table: ReactTable; + tableId: string; + density: Density; + setDensity: (d: Density) => void; + enableSavedViews: boolean; + enableColumnVisibility: boolean; + enableDensityToggle: boolean; + toolbarStart?: React.ReactNode; + toolbarEnd?: React.ReactNode; + sorting: SortingState; + setSorting: React.Dispatch>; + columnVisibility: VisibilityState; + setColumnVisibility: React.Dispatch>; + columnPinning: ColumnPinningState; + setColumnPinning: React.Dispatch>; + columnSizing: ColumnSizingState; + setColumnSizing: React.Dispatch>; + columnOrder: ColumnOrderState; + setColumnOrder: React.Dispatch>; +}) { + const currentState: PersistedState = { + sorting, + columnVisibility, + columnPinning, + columnSizing, + columnOrder, + }; + + return ( +
+
{toolbarStart}
+
+ {toolbarEnd} + {enableSavedViews && ( + { + setSorting(s.sorting); + setColumnVisibility(s.columnVisibility); + setColumnPinning(s.columnPinning); + setColumnSizing(s.columnSizing); + setColumnOrder(s.columnOrder); + }} + /> + )} + {enableColumnVisibility && } + {enableDensityToggle && ( + + )} +
+
+ ); +} + +/* ============================================================ + * ColumnsMenu — toggle visibility + pin + * ========================================================== */ + +function ColumnsMenu({ table }: { table: ReactTable }) { + const cols = table + .getAllLeafColumns() + .filter((c) => c.getCanHide() || c.getCanPin()); + + return ( + + + + + + +
+ Toggle columns +
+ {cols.map((col) => { + const isPinned = col.getIsPinned(); + const headerLabel = + typeof col.columnDef.header === "string" + ? col.columnDef.header + : col.id; + return ( +
+ + {col.getCanPin() && ( + + )} +
+ ); + })} +
+
+
+ ); +} + +/* ============================================================ + * DensityMenu + * ========================================================== */ + +function DensityMenu({ + density, + setDensity, +}: { + density: Density; + setDensity: (d: Density) => void; +}) { + return ( + + + + + + + {(["compact", "cozy", "comfortable"] as const).map((d) => ( + setDensity(d)} + className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent/60 data-[highlighted]:bg-accent/60" + > + {d} + {density === d && } + + ))} + + + + ); +} + +/* ============================================================ + * ViewsMenu — saved views + * ========================================================== */ + +function ViewsMenu({ + tableId, + currentState, + applyState, +}: { + tableId: string; + currentState: PersistedState; + applyState: (s: PersistedState) => void; +}) { + const [config, setConfig] = React.useState(() => + readJSON(viewsStorageKey(tableId)) ?? { + views: [], + activeViewId: null, + }, + ); + + const persist = (next: SavedViewsConfig) => { + setConfig(next); + writeJSON(viewsStorageKey(tableId), next); + }; + + const activeView = + config.views.find((v) => v.id === config.activeViewId) ?? null; + + const saveCurrent = () => { + const name = window.prompt("Name for this view"); + if (!name?.trim()) return; + const id = `${Date.now().toString(36)}`; + const next: SavedViewsConfig = { + views: [...config.views, { id, name: name.trim(), state: currentState }], + activeViewId: id, + }; + persist(next); + }; + + const apply = (view: SavedView) => { + applyState(view.state); + persist({ ...config, activeViewId: view.id }); + }; + + const remove = (id: string) => { + if (!confirm("Delete this view?")) return; + persist({ + views: config.views.filter((v) => v.id !== id), + activeViewId: config.activeViewId === id ? null : config.activeViewId, + }); + }; + + return ( + + + + + + +
+ Saved views +
+ {config.views.length === 0 && ( +
+ No saved views yet +
+ )} + {config.views.map((view) => ( +
+ + +
+ ))} + + + + Save current as new view… + +
+
+
+ ); +} + +/* ============================================================ + * TableShell — header + body with optional virtualization + * ========================================================== */ + +function TableShell({ + table, + allRows, + cellPadClass, + rowHeight, + height, + shouldVirtualize, + onRowClick, + rowToneAccessor, + enableColumnResize, +}: { + table: ReactTable; + allRows: Row[]; + cellPadClass: string; + rowHeight: number; + height: number | string; + shouldVirtualize: boolean; + onRowClick?: (row: T, index: number) => void; + rowToneAccessor?: (row: T) => "success" | "warning" | "danger" | "info" | undefined; + enableColumnResize: boolean; +}) { + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: allRows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => rowHeight, + overscan: 12, + enabled: shouldVirtualize, + }); + + const totalSize = shouldVirtualize ? virtualizer.getTotalSize() : "auto"; + const items = shouldVirtualize ? virtualizer.getVirtualItems() : null; + + return ( +
+ + + {table.getVisibleLeafColumns().map((col) => ( + + ))} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const col = header.column; + const isSorted = col.getIsSorted(); + const canSort = col.getCanSort(); + const align = + (col.columnDef.meta as { align?: "left" | "right" | "center" } | undefined) + ?.align ?? "left"; + const alignClass = + align === "right" + ? "text-right" + : align === "center" + ? "text-center" + : "text-left"; + const pinned = col.getIsPinned(); + return ( + + ); + })} + + ))} + + + {(shouldVirtualize ? items! : allRows.map((_, i) => ({ index: i, start: 0, size: 0, key: i }))).map( + (virtualRow) => { + const row = allRows[virtualRow.index]; + if (!row) return null; + const tone = rowToneAccessor?.(row.original); + return ( + onRowClick(row.original, virtualRow.index) : undefined} + > + {row.getVisibleCells().map((cell) => { + const col = cell.column; + const align = + (col.columnDef.meta as { align?: "left" | "right" | "center" } | undefined) + ?.align ?? "left"; + const alignClass = + align === "right" + ? "text-right" + : align === "center" + ? "text-center" + : "text-left"; + const pinned = col.getIsPinned(); + return ( + + ); + })} + + ); + }, + )} + +
+ {header.isPlaceholder ? null : ( + + {flexRender(col.columnDef.header, header.getContext())} + {canSort && ( + isSorted === "asc" ? ( + + ) : isSorted === "desc" ? ( + + ) : ( + + ) + )} + + )} + {enableColumnResize && col.getCanResize() && ( + e.stopPropagation()} + className={cn( + "absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none", + "bg-transparent hover:bg-brand-500/40", + header.column.getIsResizing() && "bg-brand-500", + )} + role="separator" + aria-orientation="vertical" + /> + )} +
+ {flexRender(col.columnDef.cell, cell.getContext())} +
+
+ ); +} + +/* ============================================================ + * Pinning helpers — sticky positioning for pinned columns + * ========================================================== */ + +function getPinStyle( + column: ReactTable["getColumn"] extends (...a: any) => infer R ? R : never, + kind: "header" | "cell", +): React.CSSProperties { + if (!column) return {}; + const pinned = column.getIsPinned(); + if (!pinned) return {}; + const offset = pinned === "left" ? column.getStart("left") : column.getAfter("right"); + return { + position: "sticky", + [pinned === "left" ? "left" : "right"]: `${offset}px`, + zIndex: kind === "header" ? 3 : 1, + }; +} + +function getPinnedWidth(table: ReactTable, side: "left" | "right"): number { + return table + .getAllLeafColumns() + .filter((c) => c.getIsPinned() === side && c.getIsVisible()) + .reduce((sum, c) => sum + c.getSize(), 0); +} + +/* ============================================================ + * Pagination + * ========================================================== */ + +function Pagination({ + table, + pageSizeOptions, +}: { + table: ReactTable; + pageSizeOptions: number[]; +}) { + const { pageIndex, pageSize } = table.getState().pagination; + const pageCount = table.getPageCount(); + const totalRows = table.getFilteredRowModel().rows.length; + const start = pageIndex * pageSize + 1; + const end = Math.min(start + pageSize - 1, totalRows); + + return ( +
+
+ + {totalRows === 0 ? 0 : `${start}–${end}`} of {totalRows} + + +
+
+ + + + {pageIndex + 1} / {Math.max(pageCount, 1)} + + + +
+
+ ); +} diff --git a/ontology_platform/web/frontend/src/components/ui/dialog.tsx b/ontology_platform/web/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..7400fef --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,214 @@ +import * as React from "react"; +import * as RadixDialog from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Dialog primitive — thin styled wrapper over @radix-ui/react-dialog. + * Radix handles focus trap, scroll lock, Escape, focus restoration, and ARIA. + * + * The exported API mirrors the previous custom implementation so consumers + * (Drawer, CommandPalette, ShortcutsOverlay) didn't need restructuring: + * + * + * + * ... + * ... + * ... + * + * + */ + +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + /** Compatibility shim — ariaLabel is now supplied on DialogPanel directly. */ + ariaLabel?: string; + /** Block close from overlay click */ + closeOnOverlayClick?: boolean; + /** Block close from Escape */ + closeOnEscape?: boolean; +} + +const DialogCtx = React.createContext<{ + ariaLabel?: string; + closeOnOverlayClick: boolean; + closeOnEscape: boolean; +}>({ closeOnOverlayClick: true, closeOnEscape: true }); + +export function Dialog({ + open, + onOpenChange, + children, + ariaLabel, + closeOnOverlayClick = true, + closeOnEscape = true, +}: DialogProps) { + return ( + + + {children} + + + ); +} + +export const DialogTrigger = RadixDialog.Trigger; +export const DialogClose = RadixDialog.Close; + +interface DialogPanelProps extends React.HTMLAttributes { + /** Accessible label when the panel has no DialogTitle */ + ariaLabel?: string; + /** Accessible description id reference */ + ariaDescribedBy?: string; +} + +export const DialogPanel = React.forwardRef( + ({ className, children, ariaLabel, ariaDescribedBy, ...props }, ref) => { + const ctx = React.useContext(DialogCtx); + const label = ariaLabel ?? ctx.ariaLabel; + return ( + + + { + if (!ctx.closeOnOverlayClick) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (!ctx.closeOnEscape) e.preventDefault(); + }} + className={cn( + "fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2", + "flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden", + "rounded-xl border border-border bg-surface-raised shadow-xl outline-none", + "data-[state=open]:animate-fade-in-up", + className, + )} + {...props} + > + {/* Radix warns if no Title is present. If consumer didn't provide one, + expose ariaLabel via VisuallyHidden Title as a fallback. */} + {label && !hasDialogTitle(children) && ( + {label} + )} + {children} + + + ); + }, +); +DialogPanel.displayName = "DialogPanel"; + +/** Best-effort detection: does the children tree include a ? */ +function hasDialogTitle(node: React.ReactNode): boolean { + let found = false; + React.Children.forEach(node, (child) => { + if (found) return; + if (!React.isValidElement(child)) return; + if (child.type === DialogTitle) { + found = true; + return; + } + const subtree = (child.props as { children?: React.ReactNode } | undefined) + ?.children; + if (subtree && hasDialogTitle(subtree)) found = true; + }); + return found; +} + +export function DialogHeader({ + className, + children, + onClose, + ...props +}: React.HTMLAttributes & { onClose?: () => void }) { + return ( +
+
{children}
+ {onClose && ( + + + + )} +
+ ); +} + +export const DialogTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +

+ +)); +DialogTitle.displayName = "DialogTitle"; + +export const DialogDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +

+ +)); +DialogDescription.displayName = "DialogDescription"; + +export function DialogBody({ + className, + ...props +}: React.HTMLAttributes) { + return ( +

+ ); +} + +export function DialogFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/ontology_platform/web/frontend/src/components/ui/drawer.tsx b/ontology_platform/web/frontend/src/components/ui/drawer.tsx new file mode 100644 index 0000000..c390751 --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/drawer.tsx @@ -0,0 +1,143 @@ +import * as React from "react"; +import * as RadixDialog from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type Side = "right" | "left"; +type Size = "sm" | "md" | "lg" | "xl"; + +const SIZE_CLASS: Record = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-2xl", + xl: "max-w-4xl", +}; + +interface DrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + side?: Side; + size?: Size; + title?: React.ReactNode; + description?: React.ReactNode; + footer?: React.ReactNode; + children?: React.ReactNode; + /** Slot rendered on the header right (e.g. tabs, badges, secondary actions) */ + headerExtra?: React.ReactNode; + className?: string; + /** Block close when clicking the overlay */ + closeOnOverlayClick?: boolean; +} + +/** + * Slide-in drawer using Radix Dialog. Comes with focus trap, scroll lock, + * Escape close, and ARIA wiring. Use for entity editors, claim reviews, + * detail inspectors — anything that benefits from preserving page context. + */ +export function Drawer({ + open, + onOpenChange, + side = "right", + size = "lg", + title, + description, + footer, + children, + headerExtra, + className, + closeOnOverlayClick = true, +}: DrawerProps) { + const slideOpen = + side === "right" + ? "data-[state=open]:animate-slide-in-right" + : "data-[state=open]:animate-fade-in-up"; + const sidePos = side === "right" ? "right-0" : "left-0"; + const borderSide = side === "right" ? "border-l" : "border-r"; + + const titleId = React.useId(); + const descId = React.useId(); + + return ( + + + + { + if (!closeOnOverlayClick) e.preventDefault(); + }} + className={cn( + "fixed top-0 bottom-0 z-50 flex w-full flex-col", + "bg-surface-raised border-border shadow-2xl outline-none", + sidePos, + borderSide, + SIZE_CLASS[size], + slideOpen, + className, + )} + > + {(title || description || headerExtra) && ( +
+
+ {title && ( + +

+ {title} +

+
+ )} + {!title && ( + // Radix requires a Title for accessibility; provide a hidden one + + Panel + + )} + {description && ( + +

+ {description} +

+
+ )} +
+
+ {headerExtra} + + + +
+
+ )} + +
+ {children} +
+ + {footer && ( +
+ {footer} +
+ )} +
+
+
+ ); +} diff --git a/ontology_platform/web/frontend/src/components/ui/empty-state.tsx b/ontology_platform/web/frontend/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..beb8025 --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/empty-state.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +type Variant = "default" | "muted" | "danger" | "info"; + +interface EmptyStateProps { + icon?: React.ComponentType<{ className?: string }>; + title: React.ReactNode; + description?: React.ReactNode; + /** Primary call-to-action (usually a + )} +
+ ); + } + + return ( +
+
+ +
+
+

{title}

+ {desc &&

{desc}

} +
+ {onRetry && ( + + )} + {detail && ( +
+ + Show technical details + +
+            {detail}
+          
+
+ )} +
+ ); +} diff --git a/ontology_platform/web/frontend/src/components/ui/filter-panel.tsx b/ontology_platform/web/frontend/src/components/ui/filter-panel.tsx new file mode 100644 index 0000000..0b2444e --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/filter-panel.tsx @@ -0,0 +1,370 @@ +import * as React from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { Check, ChevronDown, Filter, Search, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "./button"; +import { Input } from "./input"; + +/** + * Generic FilterPanel — composes a search box, several MultiSelect/Single popovers, + * range inputs, and active-filter chips into a unified toolbar. + * + * Build by composing the named sub-components. Each filter is self-contained so the + * parent only stores values and supplies onChange callbacks. Use `FilterChips` to + * render the active filter summary below the toolbar. + */ + +/* ============================================================ + * Layout primitives + * ========================================================== */ + +export function FilterPanel({ + children, + className, + toolbarClassName, + chips, + onClearAll, +}: { + children: React.ReactNode; + /** Active filter chips, e.g. from */ + chips?: React.ReactNode; + onClearAll?: () => void; + className?: string; + toolbarClassName?: string; +}) { + return ( +
+
+ + {children} +
+ {chips && ( +
+ {chips} + {onClearAll && ( + + )} +
+ )} +
+ ); +} + +/* ============================================================ + * Search box + * ========================================================== */ + +export function FilterSearch({ + value, + onChange, + placeholder = "Search…", + className, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + className?: string; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="h-9 pl-8" + /> + {value && ( + + )} +
+ ); +} + +/* ============================================================ + * Select filter (single value) — popover with options + counts + * ========================================================== */ + +export interface FilterOption { + value: string; + label: string; + /** Optional badge / count */ + hint?: React.ReactNode; + /** Optional swatch color for visual cue */ + swatch?: string; +} + +export function FilterSelect({ + label, + value, + onChange, + options, + placeholder = "All", + allowEmpty = true, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: FilterOption[]; + placeholder?: string; + allowEmpty?: boolean; +}) { + const selectedLabel = + options.find((o) => o.value === value)?.label ?? placeholder; + const hasValue = !!value; + + return ( + + + + + + +
    + {allowEmpty && ( +
  • + +
  • + )} + {options.map((opt) => { + const isSelected = opt.value === value; + return ( +
  • + +
  • + ); + })} +
+
+
+
+ ); +} + +/* ============================================================ + * Range filter (slider) — for numeric thresholds like confidence + * ========================================================== */ + +export function FilterRange({ + label, + value, + onChange, + min = 0, + max = 1, + step = 0.05, + format = (v) => `${Math.round(v * 100)}%`, +}: { + label: string; + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; + format?: (v: number) => string; +}) { + const active = value > min; + return ( + + + + + + +
+ {label} + {format(value)} +
+ onChange(Number(e.target.value))} + className="w-full" + /> +
+ {format(min)} + {format(max)} +
+
+
+
+ ); +} + +/* ============================================================ + * Toggle (checkbox-style chip) + * ========================================================== */ + +export function FilterToggle({ + label, + checked, + onChange, + disabled, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +/* ============================================================ + * Active filter chips + * ========================================================== */ + +export interface ActiveChip { + id: string; + label: string; + value: string; + onClear: () => void; +} + +export function FilterChips({ chips }: { chips: ActiveChip[] }) { + if (chips.length === 0) { + return ( + No active filters + ); + } + return ( + <> + {chips.map((chip) => ( + + + {chip.label}: + + {chip.value} + + + ))} + + ); +} + +/* ============================================================ + * Refresh / view actions slot (placed on the right of toolbar) + * ========================================================== */ + +export function FilterActions({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +// Re-export Button for convenience when composing inline +export { Button }; diff --git a/ontology_platform/web/frontend/src/components/ui/loading-state.tsx b/ontology_platform/web/frontend/src/components/ui/loading-state.tsx new file mode 100644 index 0000000..e93b5e5 --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/loading-state.tsx @@ -0,0 +1,90 @@ +import * as React from "react"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "./skeleton"; + +type Variant = "spinner" | "skeleton" | "list" | "table"; + +interface LoadingStateProps { + variant?: Variant; + /** Optional message under the indicator */ + message?: React.ReactNode; + /** For skeleton variants: row count */ + rows?: number; + /** Compact vertical padding */ + compact?: boolean; + className?: string; +} + +/** + * Standardized loading indicator. Pick a variant that matches what's coming: + * - `spinner`: indeterminate work (mutations, single resources) + * - `skeleton`: rectangular placeholder (single block) + * - `list`: vertical list of rows + * - `table`: dense grid of rows × 4 cols + */ +export function LoadingState({ + variant = "spinner", + message, + rows = 4, + compact = false, + className, +}: LoadingStateProps) { + if (variant === "spinner") { + return ( +
+ + {message && {message}} + Loading +
+ ); + } + + if (variant === "skeleton") { + return ; + } + + if (variant === "list") { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ); + } + + // table + return ( +
+
+ +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} diff --git a/ontology_platform/web/frontend/src/components/ui/shortcuts-overlay.tsx b/ontology_platform/web/frontend/src/components/ui/shortcuts-overlay.tsx new file mode 100644 index 0000000..cefa11e --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/shortcuts-overlay.tsx @@ -0,0 +1,147 @@ +import * as React from "react"; +import { Keyboard } from "lucide-react"; +import { + Dialog, + DialogPanel, + DialogHeader, + DialogTitle, + DialogDescription, + DialogBody, +} from "./dialog"; + +export interface ShortcutEntry { + keys: string; + label: string; +} + +export interface ShortcutGroup { + title: string; + items: ShortcutEntry[]; +} + +interface ShortcutsOverlayProps { + open: boolean; + onOpenChange: (open: boolean) => void; + groups: ShortcutGroup[]; + title?: string; + description?: string; +} + +/** + * Modal listing keyboard shortcuts. Pair with the `?` binding to open it. + * + * Each `keys` entry uses tokens separated by spaces for chord (e.g. "g d") + * or `+` for modifier combos (e.g. "Mod+K", "Shift+/"). Tokens are rendered + * as individual chips. + */ +export function ShortcutsOverlay({ + open, + onOpenChange, + groups, + title = "Keyboard shortcuts", + description = "Press the key combination to trigger the action.", +}: ShortcutsOverlayProps) { + return ( + + + onOpenChange(false)}> +
+ + + +
+ {title} + {description} +
+
+
+ +
+ {groups.map((group) => ( +
+

+ {group.title} +

+
    + {group.items.map((item) => ( +
  • + + {item.label} + + +
  • + ))} +
+
+ ))} +
+
+
+
+ ); +} + +function ShortcutChips({ keys }: { keys: string }) { + // chord (space) → sequence; modifier (+) → group + const trimmed = keys.trim(); + if (trimmed.includes(" ")) { + const parts = trimmed.split(/\s+/); + return ( + + {parts.map((p, i) => ( + + {p} + {i < parts.length - 1 && ( + then + )} + + ))} + + ); + } + if (trimmed.includes("+")) { + const parts = trimmed.split("+").map((p) => p.trim()); + return ( + + {parts.map((p, i) => ( + + {prettifyKey(p)} + {i < parts.length - 1 && ( + + + )} + + ))} + + ); + } + return {prettifyKey(trimmed)}; +} + +function prettifyKey(k: string): string { + const lower = k.toLowerCase(); + if (typeof navigator === "undefined") return k; + const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + if (lower === "mod") return isMac ? "⌘" : "Ctrl"; + if (lower === "cmd" || lower === "meta") return isMac ? "⌘" : "Win"; + if (lower === "ctrl") return "Ctrl"; + if (lower === "shift") return "⇧"; + if (lower === "alt" || lower === "option") return isMac ? "⌥" : "Alt"; + if (lower === "enter") return "↵"; + if (lower === "escape" || lower === "esc") return "Esc"; + if (lower === "arrowup") return "↑"; + if (lower === "arrowdown") return "↓"; + if (lower === "arrowleft") return "←"; + if (lower === "arrowright") return "→"; + return k.length === 1 ? k.toUpperCase() : k; +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/ontology_platform/web/frontend/src/components/ui/tooltip.tsx b/ontology_platform/web/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..56e4091 --- /dev/null +++ b/ontology_platform/web/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/utils"; + +/** + * Thin wrapper over Radix Tooltip with project styling. + * Wrap your tree in once (in main.tsx). + */ + +export const TooltipProvider = TooltipPrimitive.Provider; + +export interface TooltipProps { + children: React.ReactNode; + content: React.ReactNode; + /** Side relative to the trigger */ + side?: "top" | "right" | "bottom" | "left"; + /** Alignment along the side axis */ + align?: "start" | "center" | "end"; + /** Open delay in ms. Default 200. */ + delayMs?: number; + /** Show keyboard shortcut chip after content */ + shortcut?: string; +} + +export function Tooltip({ + children, + content, + side = "top", + align = "center", + delayMs = 200, + shortcut, +}: TooltipProps) { + return ( + + {children} + + + {content} + {shortcut && ( + + {shortcut} + + )} + + + + + ); +} diff --git a/ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts b/ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..6ba87b1 --- /dev/null +++ b/ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef } from "react"; + +/** + * Generic keyboard shortcut hook supporting two forms: + * - Single keys / modifier combos: "?", "/", "Mod+K", "Shift+/" + * - Chord (leader-follower) sequences: "g d", "g n" — leader pressed then + * follower within `chordWindowMs`. Inspired by Linear / GitHub. + * + * Bindings is a record of `pattern -> handler`. Patterns are case-insensitive. + * Use "Mod" for Ctrl on Windows/Linux and Cmd on macOS. + * + * The hook ignores key events whose target is an editable element (input, + * textarea, contenteditable) UNLESS the pattern uses a modifier (Mod/Shift/Alt). + */ + +export interface UseKeyboardShortcutsOptions { + /** Disable all bindings (e.g. while a modal is open) */ + disabled?: boolean; + /** Time window in ms for a chord follower after leader. Default 1500. */ + chordWindowMs?: number; + /** Fired when a leader key starts a chord, useful for UI hints. */ + onChordStart?: (leader: string) => void; + /** Fired when a chord times out or is cancelled. */ + onChordEnd?: () => void; +} + +type Handler = (event: KeyboardEvent) => void; + +function isEditable(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + if (target.isContentEditable) return true; + return false; +} + +function isMac(): boolean { + if (typeof navigator === "undefined") return false; + return /Mac|iPod|iPhone|iPad/.test(navigator.platform); +} + +interface ParsedSingle { + kind: "single"; + key: string; // lowercase + mod: boolean; + shift: boolean; + alt: boolean; +} + +interface ParsedChord { + kind: "chord"; + leader: string; // lowercase + follower: string; // lowercase +} + +function parsePattern(pattern: string): ParsedSingle | ParsedChord { + const trimmed = pattern.trim(); + if (trimmed.includes(" ")) { + const [leader, follower] = trimmed.toLowerCase().split(/\s+/); + return { kind: "chord", leader, follower }; + } + const parts = trimmed.split("+").map((p) => p.trim().toLowerCase()); + const key = parts.pop() ?? ""; + const set = new Set(parts); + return { + kind: "single", + key, + mod: set.has("mod") || set.has("ctrl") || set.has("cmd") || set.has("meta"), + shift: set.has("shift"), + alt: set.has("alt") || set.has("option"), + }; +} + +function keyMatches(event: KeyboardEvent, parsed: ParsedSingle): boolean { + const evKey = event.key.toLowerCase(); + if (evKey !== parsed.key) return false; + const mod = isMac() ? event.metaKey : event.ctrlKey; + if (parsed.mod !== mod) return false; + if (parsed.shift !== event.shiftKey) return false; + if (parsed.alt !== event.altKey) return false; + return true; +} + +export function useKeyboardShortcuts( + bindings: Record, + opts: UseKeyboardShortcutsOptions = {}, +) { + const bindingsRef = useRef(bindings); + bindingsRef.current = bindings; + const optsRef = useRef(opts); + optsRef.current = opts; + + useEffect(() => { + const pending = { leader: null as string | null, timer: 0 }; + + const clearChord = () => { + if (pending.timer) { + window.clearTimeout(pending.timer); + pending.timer = 0; + } + if (pending.leader) { + pending.leader = null; + optsRef.current.onChordEnd?.(); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (optsRef.current.disabled) return; + const editable = isEditable(event.target); + const evKey = event.key.toLowerCase(); + + // Parse all bindings on each press (cheap; ~15 entries) + const entries = Object.entries(bindingsRef.current).map( + ([pattern, handler]) => ({ pattern, parsed: parsePattern(pattern), handler }), + ); + + // 1) Follower phase — if a leader is pending, look for chord match. + if (pending.leader) { + const match = entries.find( + (e) => + e.parsed.kind === "chord" && + e.parsed.leader === pending.leader && + e.parsed.follower === evKey, + ); + if (match) { + event.preventDefault(); + clearChord(); + match.handler(event); + return; + } + // Any other key cancels the chord + clearChord(); + // ... and falls through to normal handling below. + } + + // 2) Single key / modifier combos + for (const e of entries) { + if (e.parsed.kind !== "single") continue; + const hasMod = e.parsed.mod || e.parsed.shift || e.parsed.alt; + if (editable && !hasMod) continue; + if (keyMatches(event, e.parsed)) { + event.preventDefault(); + e.handler(event); + return; + } + } + + // 3) Leader phase — start a chord if this key is a leader and no + // modifier was pressed (chords are bare keys). + if (editable) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + const leaders = new Set( + entries + .filter((e) => e.parsed.kind === "chord") + .map((e) => (e.parsed as ParsedChord).leader), + ); + if (leaders.has(evKey)) { + pending.leader = evKey; + optsRef.current.onChordStart?.(evKey); + pending.timer = window.setTimeout( + clearChord, + optsRef.current.chordWindowMs ?? 1500, + ); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + clearChord(); + }; + }, []); +} diff --git a/ontology_platform/web/frontend/src/hooks/useTheme.ts b/ontology_platform/web/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..c3d7cae --- /dev/null +++ b/ontology_platform/web/frontend/src/hooks/useTheme.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/stores"; + +/** + * Syncs the Redux theme state to the `` element's `dark` class. + * Honors system preference when mode === "system" and reacts to OS changes. + */ +export function useThemeSync() { + const mode = useSelector((s: RootState) => s.ui.theme); + + useEffect(() => { + const root = document.documentElement; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + + const apply = () => { + const resolved = + mode === "system" ? (media.matches ? "dark" : "light") : mode; + root.classList.toggle("dark", resolved === "dark"); + root.dataset.theme = resolved; + }; + + apply(); + + if (mode === "system") { + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + } + }, [mode]); +} diff --git a/ontology_platform/web/frontend/src/main.tsx b/ontology_platform/web/frontend/src/main.tsx index 5c461f8..6dd7585 100644 --- a/ontology_platform/web/frontend/src/main.tsx +++ b/ontology_platform/web/frontend/src/main.tsx @@ -8,17 +8,23 @@ import "./i18n"; import App from "./App"; import store from "./stores"; import { queryClient } from "./lib/queryClient"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; import "./styles/globals.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - - - + + + + + + + + + + + + ); diff --git a/ontology_platform/web/frontend/src/pages/ConfigureSourcesPage.tsx b/ontology_platform/web/frontend/src/pages/ConfigureSourcesPage.tsx index cc773b0..22c57df 100644 --- a/ontology_platform/web/frontend/src/pages/ConfigureSourcesPage.tsx +++ b/ontology_platform/web/frontend/src/pages/ConfigureSourcesPage.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useForm } from "react-hook-form"; @@ -5,28 +6,38 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { toast } from "sonner"; import { - AlertCircle, - ArrowLeft, ArrowRight, ExternalLink, + Globe2, Loader2, Plus, + Settings2, Trash2, } from "lucide-react"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; +import { DataTable, type ColumnDef } from "@/components/ui/data-table"; +import { Drawer } from "@/components/ui/drawer"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ErrorState } from "@/components/ui/error-state"; +import { PageHeader } from "@/components/layout/PageHeader"; import { useProject } from "@/hooks/useProjects"; import { useCreateSource, useDeleteSource } from "@/hooks/useSources"; +type SourceRow = { + id: string; + name: string; + type: string; + base_url?: string | null; + trust_level?: string | number | null; + respect_robots_txt?: boolean | null; + rate_limit_per_minute?: number | null; +}; + +const toNumber = (v: string | number | null | undefined): number | null => + v == null ? null : typeof v === "number" ? v : Number(v); + const SOURCE_TYPES = [ "official", "review", @@ -62,6 +73,15 @@ const sourceSchema = z.object({ type SourceFormValues = z.infer; +const TYPE_TONE: Record = { + official: "pill-info", + review: "pill-warning", + blog: "pill-muted", + news: "pill-info", + community: "pill-muted", + unknown: "pill-muted", +}; + export default function ConfigureSourcesPage() { const navigate = useNavigate(); const { projectId } = useParams<{ projectId: string }>(); @@ -79,6 +99,8 @@ export default function ConfigureSourcesPage() { const createSource = useCreateSource(projectName); const deleteSource = useDeleteSource(projectName); + const [addOpen, setAddOpen] = useState(false); + const { register, handleSubmit, @@ -96,6 +118,11 @@ export default function ConfigureSourcesPage() { }, }); + const openAdd = () => { + reset(); + setAddOpen(true); + }; + const onAddSource = async (values: SourceFormValues) => { try { await createSource.mutateAsync({ @@ -108,6 +135,7 @@ export default function ConfigureSourcesPage() { }), ); reset(); + setAddOpen(false); } catch (e) { toast.error( t("sources.addFailed", "추가 실패: {{msg}}", { @@ -143,269 +171,327 @@ export default function ConfigureSourcesPage() { } }; - return ( -
-
- -
-

- {t("sources.title", "참고 소스 설정")} -

- {project && ( -

- {project.name}{" "} - - · {project.domain} - -

- )} + const sources = project?.sources ?? []; + + const columns: ColumnDef[] = [ + { + id: "name", + header: t("sources.name", "이름"), + accessorFn: (row) => row.name, + cell: ({ row }) => ( +
+ + + + {row.original.name}
- -
+ ), + size: 240, + enablePinning: true, + }, + { + id: "type", + header: t("sources.type", "타입"), + accessorFn: (row) => row.type, + cell: ({ row }) => ( + + {row.original.type} + + ), + size: 120, + }, + { + id: "base_url", + header: "Base URL", + accessorFn: (row) => row.base_url ?? "", + enableSorting: false, + cell: ({ row }) => + row.original.base_url ? ( + e.stopPropagation()} + className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline" + > + + + {row.original.base_url} + + + ) : ( + + ), + size: 280, + }, + { + id: "trust", + header: t("sources.trust", "신뢰도"), + accessorFn: (row) => toNumber(row.trust_level) ?? 0, + cell: ({ row }) => { + const n = toNumber(row.original.trust_level); + return n == null ? ( + + ) : ( + + ); + }, + size: 140, + meta: { align: "right" }, + }, + { + id: "rate", + header: t("sources.rateLimit", "rate/분"), + accessorFn: (row) => row.rate_limit_per_minute ?? 0, + cell: ({ row }) => ( + + {row.original.rate_limit_per_minute ?? "—"} + + ), + size: 110, + meta: { align: "right" }, + }, + { + id: "actions", + header: "", + enableSorting: false, + enableHiding: false, + enableResizing: false, + cell: ({ row }) => ( +
+ +
+ ), + size: 60, + meta: { align: "right" }, + }, + ]; - {isError && ( - - -
- - {(error as Error).message} -
- -
-
- )} + + + } + /> -
-
- - - {t("sources.listTitle", "등록된 소스")} - - {t( - "sources.listDesc", - "프로젝트 온톨로지 구축에 사용할 참고 사이트 목록", - )} - - - - {isLoading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
+
+ {isError && ( + refetch()} + /> + )} + + + tableId="sources" + columns={columns} + data={sources} + getRowId={(row) => row.id} + loading={isLoading} + loadingRows={4} + height={560} + empty={ + - {t( - "sources.empty", - "아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.", - )} -

- )} - - {project && project.sources.length > 0 && ( -
    - {project.sources.map((s) => ( -
  • -
    -
    - {s.name} - - {s.type} - - {typeof s.trust_level === "number" && ( - - {t("sources.trust", "신뢰도")}{" "} - {s.trust_level.toFixed(2)} - - )} -
    - {s.base_url && ( - - - {s.base_url} - - )} -
    - -
  • - ))} -
- )} - - -
- - + } + /> + } + />
+ + + + + + } + > +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+ +
+ + + {errors.base_url && ( +

{errors.base_url.message}

+ )} +
+ +
+
+ + + {errors.trust_level && ( +

+ {errors.trust_level.message} +

+ )} +
+
+ + + {errors.rate_limit_per_minute && ( +

+ {errors.rate_limit_per_minute.message} +

+ )} +
+
+ + +
+
+ + ); +} + +function TrustBar({ value }: { value: number }) { + const pct = Math.round(value * 100); + const tone = + value >= 0.75 ? "bg-success" : value >= 0.4 ? "bg-warning" : "bg-danger"; + return ( +
+
+
+
+ + {value.toFixed(2)} +
); } diff --git a/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx b/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx index d6c0871..fd2322d 100644 --- a/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx +++ b/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx @@ -2,28 +2,45 @@ import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AlertCircle, - ArrowLeft, CircleDot, + Eye, + EyeOff, GitBranch, + Keyboard, + Layers, + Map as MapIcon, Network, RefreshCw, - Search, - SlidersHorizontal, } from "lucide-react"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; -import { Select } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { Drawer } from "@/components/ui/drawer"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Tooltip } from "@/components/ui/tooltip"; +import { + FilterPanel, + FilterSearch, + FilterSelect, + FilterRange, + FilterToggle, + FilterChips, + type ActiveChip, +} from "@/components/ui/filter-panel"; +import { + ShortcutsOverlay, + type ShortcutGroup, +} from "@/components/ui/shortcuts-overlay"; +import { PageHeader } from "@/components/layout/PageHeader"; import { useGraphNeighborhood } from "@/hooks/usePlatform"; +import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useProject } from "@/hooks/useProjects"; import { GraphEdge, GraphNode } from "@/lib/api/platform"; import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display"; @@ -39,6 +56,9 @@ const COLORS = [ "#dc2626", ]; +const VIEW_W = 860; +const VIEW_H = 600; + interface VisualNode { id: string; name: string; @@ -150,8 +170,8 @@ function buildGraph( visualNodes = visualNodes.filter((node) => connectedIds.has(node.id)); } - const centerX = 430; - const centerY = 300; + const centerX = VIEW_W / 2; + const centerY = VIEW_H / 2; const radius = Math.max(160, Math.min(260, visualNodes.length * 13)); visualNodes.forEach((node, index) => { const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1); @@ -179,6 +199,12 @@ export default function GraphViewPage() { const [entityType, setEntityType] = useState(""); const [minConfidence, setMinConfidence] = useState(0); const [selected, setSelected] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [hiddenTypes, setHiddenTypes] = useState>(new Set()); + const [showLegend, setShowLegend] = useState(true); + const [showMinimap, setShowMinimap] = useState(true); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const graph = useGraphNeighborhood(projectName, { includeCandidates: includeCandidates && statusScope === "validated_claim", limit: 300, @@ -212,297 +238,428 @@ export default function GraphViewPage() { search, ], ); - const averageConfidence = visual.edges.length - ? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) / - visual.edges.length + + // Filter out types the user toggled off in the legend + const visibleNodes = useMemo( + () => visual.nodes.filter((n) => !hiddenTypes.has(n.type)), + [visual.nodes, hiddenTypes], + ); + const visibleNodeIds = useMemo( + () => new Set(visibleNodes.map((n) => n.id)), + [visibleNodes], + ); + const visibleEdges = useMemo( + () => + visual.edges.filter( + (e) => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target), + ), + [visual.edges, visibleNodeIds], + ); + const typeCounts = useMemo(() => { + const counts = new Map(); + visual.nodes.forEach((n) => + counts.set(n.type, (counts.get(n.type) ?? 0) + 1), + ); + return counts; + }, [visual.nodes]); + + const averageConfidence = visibleEdges.length + ? visibleEdges.reduce((sum, edge) => sum + edge.confidence, 0) / + visibleEdges.length : 0; - return ( -
-
- -
-

- - Graph View -

-

- {project?.name ?? projectName} relation graph, candidates, and edge evidence. -

-
- -
+ const selectNode = (node: VisualNode | VisualEdge) => { + setSelected(node); + setDrawerOpen(true); + }; - {graph.isError && ( - - + const toggleType = (type: string) => { + setHiddenTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }; + + // Page-level shortcuts (disabled while drawer or overlay is open) + useKeyboardShortcuts( + { + l: () => setShowLegend((v) => !v), + m: () => setShowMinimap((v) => !v), + r: () => graph.refetch(), + "Shift+?": () => setShortcutsOpen(true), + Escape: () => { + if (drawerOpen) { + setDrawerOpen(false); + setSelected(null); + } + }, + }, + { disabled: shortcutsOpen }, + ); + + const pageShortcutGroups: ShortcutGroup[] = [ + { + title: "Graph View", + items: [ + { keys: "l", label: "Toggle legend" }, + { keys: "m", label: "Toggle minimap" }, + { keys: "r", label: "Refresh graph" }, + { keys: "Escape", label: "Close detail drawer" }, + ], + }, + { + title: "Global", + items: [ + { keys: "Mod+K", label: "Command palette" }, + { keys: "Shift+?", label: "Show this panel" }, + { keys: "g g", label: "Jump to Graph View" }, + ], + }, + ]; + + return ( + <> + + + + } + /> + +
+ {graph.isError && ( +
{(graph.error as Error).message} - - - )} +
+ )} -
-
- - setSearch(event.target.value)} - className="pl-9" - placeholder="Search node or type" - /> -
- - - - -
+ {/* Filter bar — unified FilterPanel */} + {(() => { + const statusLabels: Record = { + validated_claim: "Validated", + candidate_claim: "Candidate", + rule_candidate: "Rule candidate", + active: "Active", + all: "All statuses", + }; + const chips: ActiveChip[] = []; + if (search) + chips.push({ + id: "search", + label: "Search", + value: `"${search}"`, + onClear: () => setSearch(""), + }); + if (entityType) + chips.push({ + id: "type", + label: "Type", + value: entityType, + onClear: () => setEntityType(""), + }); + if (predicate) + chips.push({ + id: "predicate", + label: "Predicate", + value: predicate, + onClear: () => setPredicate(""), + }); + if (statusScope !== "validated_claim") + chips.push({ + id: "status", + label: "Status", + value: statusLabels[statusScope] ?? statusScope, + onClear: () => setStatusScope("validated_claim"), + }); + if (minConfidence > 0) + chips.push({ + id: "conf", + label: "Min confidence", + value: formatPercent(minConfidence), + onClear: () => setMinConfidence(0), + }); + if (!includeCandidates && statusScope === "validated_claim") + chips.push({ + id: "blend", + label: "Blend candidates", + value: "off", + onClear: () => setIncludeCandidates(true), + }); -
-
- - - Minimum confidence - - {formatPercent(minConfidence)} -
- setMinConfidence(Number(event.target.value))} - className="w-full" - /> -
+ const clearAll = () => { + setSearch(""); + setEntityType(""); + setPredicate(""); + setStatusScope("validated_claim"); + setMinConfidence(0); + setIncludeCandidates(true); + }; -
- - -
-
- Ontology Network - - {visual.nodes.length} nodes / {visual.edges.length} edges shown - -
-
- {visual.types.slice(0, 8).map((type) => ( - - - {type} - - ))} -
+ return ( + 0 ? : undefined} + onClearAll={chips.length > 0 ? clearAll : undefined} + > + + ({ + value: t, + label: t, + hint: typeCounts.get(t), + }))} + /> + ({ value: p, label: p }))} + /> + setStatusScope(v || "validated_claim")} + placeholder="Validated" + allowEmpty + options={Object.entries(statusLabels) + .filter(([k]) => k !== "validated_claim") + .map(([k, v]) => ({ value: k, label: v }))} + /> + `${Math.round(v * 100)}%`} + /> + + + ); + })()} + + {/* Canvas */} +
+
+
+ + Ontology Network + + + {visibleNodes.length} nodes / {visibleEdges.length} edges shown +
- - +
+ + + + + + + + + +
+
+ +
{graph.isLoading ? ( - ) : visual.nodes.length === 0 ? ( -
- - No graph is available for the current filters. -
+ ) : visibleNodes.length === 0 ? ( + ) : ( - - - - - - - {visual.edges.map((edge) => { - const { source, target } = edgeEndpoint(edge, visual.nodes); - if (!source || !target) return null; - const midX = (source.x + target.x) / 2; - const midY = (source.y + target.y) / 2; - return ( - setSelected(edge)} + <> + + + - - + + + {visibleEdges.map((edge) => { + const { source, target } = edgeEndpoint(edge, visibleNodes); + if (!source || !target) return null; + const midX = (source.x + target.x) / 2; + const midY = (source.y + target.y) / 2; + const isSelected = + selected && "predicate" in selected && selected.id === edge.id; + return ( + selectNode(edge)} > - {edge.predicate} - - - ); - })} - {visual.nodes.map((node) => ( - setSelected(node)} - > - - - {node.name.length > 24 - ? `${node.name.slice(0, 24)}...` - : node.name} - - - ))} - + + + {edge.predicate} + + + ); + })} + {visibleNodes.map((node) => { + const isSelected = + selected && "id" in selected && selected.id === node.id; + return ( + selectNode(node)} + > + + + {node.name.length > 24 + ? `${node.name.slice(0, 24)}...` + : node.name} + + + ); + })} + + + {/* Legend overlay (top-right) */} + {showLegend && ( + + )} + + {/* Minimap overlay (bottom-right) */} + {showMinimap && visibleNodes.length > 0 && ( + + )} + )} - - +
+
- +
+ + {/* Node / Edge detail drawer */} + { + setDrawerOpen(open); + if (!open) setSelected(null); + }} + size="md" + title={ + selected + ? "predicate" in selected + ? `Edge · ${selected.predicate}` + : `Node · ${selected.name}` + : "" + } + description={ + selected && "predicate" in selected + ? "Claim evidence and confidence" + : "Entity details and metadata" + } + headerExtra={ + selected && ( + + {"predicate" in selected ? selected.predicate : selected.type} + + ) + } + footer={ + + } + > + {selected && "predicate" in selected && ( +
+ + + + + + +
+ )} + {selected && !("predicate" in selected) && ( +
+ + + + + +
+ )} +
+ + + + ); +} + +/* ============================================================ + * Legend Panel — overlay, toggle visibility per type + * ========================================================== */ +function LegendPanel({ + types, + typeCounts, + hiddenTypes, + onToggle, +}: { + types: string[]; + typeCounts: Map; + hiddenTypes: Set; + onToggle: (type: string) => void; +}) { + return ( +
+
+ Legend +
+
    + {types.map((type) => { + const hidden = hiddenTypes.has(type); + const count = typeCounts.get(type) ?? 0; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +/* ============================================================ + * Minimap — scaled overview of the whole graph + * ========================================================== */ +function Minimap({ + nodes, + edges, +}: { + nodes: VisualNode[]; + edges: VisualEdge[]; +}) { + const W = 180; + const H = 130; + // Bounds of visible nodes (with padding so circles don't clip) + const pad = 24; + const xs = nodes.map((n) => n.x); + const ys = nodes.map((n) => n.y); + const minX = Math.min(...xs) - pad; + const maxX = Math.max(...xs) + pad; + const minY = Math.min(...ys) - pad; + const maxY = Math.max(...ys) + pad; + const w = Math.max(maxX - minX, 1); + const h = Math.max(maxY - minY, 1); + const scale = Math.min(W / w, H / h); + const offsetX = (W - w * scale) / 2; + const offsetY = (H - h * scale) / 2; + const tx = (x: number) => (x - minX) * scale + offsetX; + const ty = (y: number) => (y - minY) * scale + offsetY; + + return ( +
+
+ Minimap + + {nodes.length}·{edges.length} + +
+
); } function Detail({ label, value }: { label: string; value: unknown }) { return ( -
-
{label}
-
{humanizeValue(value)}
+
+
+ {label} +
+
+ {humanizeValue(value)} +
); } @@ -559,7 +949,7 @@ function HealthRow({ label, value }: { label: string; value: number | string }) return (
{label} - + {typeof value === "number" ? value.toLocaleString() : value}
diff --git a/ontology_platform/web/frontend/src/pages/ReviewPage.tsx b/ontology_platform/web/frontend/src/pages/ReviewPage.tsx index 46487fc..6c2a54c 100644 --- a/ontology_platform/web/frontend/src/pages/ReviewPage.tsx +++ b/ontology_platform/web/frontend/src/pages/ReviewPage.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { AlertCircle, ArrowLeft, + ArrowRight, Check, Eye, Filter, @@ -11,6 +12,7 @@ import { Search, X, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { Card, CardContent, @@ -168,23 +170,23 @@ export default function ReviewPage() {
-
- +
+ -
-
+
+
Review Queue 신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
-
+
setSearch(event.target.value)} - className="w-56 pl-9" + className="w-48 pl-9 sm:w-56" placeholder="Search claims" />
@@ -193,7 +195,7 @@ export default function ReviewPage() {