1
This commit is contained in:
Binary file not shown.
@@ -4,7 +4,40 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="description" content="Ontology Construction Platform with Phase 5 GraphRAG and Phase 7 LLM" />
|
<meta name="description" content="Ontology Construction Platform with Phase 5 GraphRAG and Phase 7 LLM" />
|
||||||
<title>Ontology Builder - AI-Powered Ontology Construction</title>
|
<meta name="theme-color" content="#0b1020" />
|
||||||
|
<title>Ontology Builder — AI-Powered Ontology Construction</title>
|
||||||
|
|
||||||
|
<!-- Avoid FOUC on dark mode: apply theme before stylesheets -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("op.theme");
|
||||||
|
var prefersDark =
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
var theme = stored === "light" || stored === "dark"
|
||||||
|
? stored
|
||||||
|
: prefersDark ? "dark" : "light";
|
||||||
|
if (theme === "dark") document.documentElement.classList.add("dark");
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Fonts: Pretendard (KR/EN), Inter (display), JetBrains Mono (code) -->
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
as="style"
|
||||||
|
crossorigin
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
912
ontology_platform/web/frontend/package-lock.json
generated
912
ontology_platform/web/frontend/package-lock.json
generated
@@ -9,8 +9,14 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@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",
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
"@tanstack/react-query": "^5.28.0",
|
"@tanstack/react-query": "^5.28.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cytoscape": "^3.33.3",
|
"cytoscape": "^3.33.3",
|
||||||
@@ -786,6 +792,44 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
||||||
@@ -883,6 +927,669 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@reactflow/background": {
|
||||||
"version": "11.3.14",
|
"version": "11.3.14",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||||
@@ -1440,6 +2147,66 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1857,6 +2624,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -2443,6 +3228,15 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -3180,6 +3974,53 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||||
@@ -3212,6 +4053,28 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/reactflow": {
|
||||||
"version": "11.11.4",
|
"version": "11.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||||
@@ -3581,6 +4444,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -3626,6 +4495,49 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
|||||||
@@ -21,8 +21,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@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",
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
"@tanstack/react-query": "^5.28.0",
|
"@tanstack/react-query": "^5.28.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cytoscape": "^3.33.3",
|
"cytoscape": "^3.33.3",
|
||||||
|
|||||||
@@ -6,12 +6,36 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"onboard": "New Project",
|
"onboard": "New Project",
|
||||||
"sources": "Sources",
|
"sources": "Source Explorer",
|
||||||
"crawl": "Crawl",
|
"crawl": "Seed Crawl",
|
||||||
"research": "Research",
|
"pipeline": "Build Pipeline",
|
||||||
"editor": "Editor",
|
"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",
|
"review": "Review",
|
||||||
"toggleSidebar": "Toggle sidebar"
|
"publish": "Publish"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "Theme",
|
||||||
|
"toggle": "Switch theme"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Ontology Builder",
|
"title": "Ontology Builder",
|
||||||
|
|||||||
@@ -8,10 +8,34 @@
|
|||||||
"onboard": "프로젝트 생성",
|
"onboard": "프로젝트 생성",
|
||||||
"sources": "참고 소스",
|
"sources": "참고 소스",
|
||||||
"crawl": "크롤 진행",
|
"crawl": "크롤 진행",
|
||||||
|
"pipeline": "빌드 파이프라인",
|
||||||
|
"analysis": "페이지 분석",
|
||||||
|
"schema": "스키마 디자이너",
|
||||||
"research": "자율 연구",
|
"research": "자율 연구",
|
||||||
"editor": "온톨로지 편집",
|
"editor": "온톨로지 편집",
|
||||||
"review": "결과 검토",
|
"review": "결과 검토",
|
||||||
"toggleSidebar": "사이드바 토글"
|
"quality": "품질 검사",
|
||||||
|
"graph": "그래프 보기",
|
||||||
|
"export": "내보내기 / API",
|
||||||
|
"toggleSidebar": "사이드바 토글",
|
||||||
|
"openCommandPalette": "커맨드 팔레트 열기",
|
||||||
|
"searchPlaceholder": "페이지·프로젝트 검색…",
|
||||||
|
"help": "도움말",
|
||||||
|
"account": "계정",
|
||||||
|
"projects": "프로젝트",
|
||||||
|
"noProjects": "등록된 프로젝트가 없습니다",
|
||||||
|
"noProjectSelected": "프로젝트 선택",
|
||||||
|
"selectProjectFirst": "먼저 대시보드에서 프로젝트를 선택하세요"
|
||||||
|
},
|
||||||
|
"navGroup": {
|
||||||
|
"workspace": "워크스페이스",
|
||||||
|
"build": "구축",
|
||||||
|
"review": "검토",
|
||||||
|
"publish": "배포"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "테마",
|
||||||
|
"toggle": "테마 전환"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "온톨로지 빌더",
|
"title": "온톨로지 빌더",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||||
|
<ErrorState
|
||||||
|
severity="error"
|
||||||
|
title="Something went wrong"
|
||||||
|
description="An unexpected error broke this view. You can try reloading just this section, or refresh the page."
|
||||||
|
detail={error.stack ?? error.message}
|
||||||
|
onRetry={this.reset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
matchPath,
|
matchPath,
|
||||||
NavLink,
|
NavLink,
|
||||||
@@ -7,69 +7,39 @@ import {
|
|||||||
useNavigate,
|
useNavigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
ChevronDown,
|
||||||
UploadCloud,
|
ChevronsLeft,
|
||||||
Settings2,
|
ChevronsRight,
|
||||||
Activity,
|
Command,
|
||||||
Brain,
|
HelpCircle,
|
||||||
Code2,
|
Search,
|
||||||
GitBranch,
|
Sparkles,
|
||||||
Network,
|
|
||||||
ListChecks,
|
|
||||||
Menu,
|
|
||||||
SearchCheck,
|
|
||||||
ShieldCheck,
|
|
||||||
ClipboardCheck,
|
|
||||||
Layers3,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { RootState } from "@/stores";
|
import { RootState } from "@/stores";
|
||||||
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useProjects } from "@/hooks/useProjects";
|
import { useProjects } from "@/hooks/useProjects";
|
||||||
|
import { useThemeSync } from "@/hooks/useTheme";
|
||||||
interface NavItem {
|
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||||
to?: string;
|
import { ThemeToggle } from "@/components/layout/ThemeToggle";
|
||||||
projectPath?: string;
|
import {
|
||||||
labelKey: string;
|
ShortcutsOverlay,
|
||||||
label: string;
|
type ShortcutGroup,
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
} from "@/components/ui/shortcuts-overlay";
|
||||||
}
|
import { Tooltip } from "@/components/ui/tooltip";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
const navItems: NavItem[] = [
|
import { NAV_GROUPS, PROJECT_ROUTE_PATTERNS, type NavItem } from "./navigation";
|
||||||
{ to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard },
|
import {
|
||||||
{ to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud },
|
CommandPalette,
|
||||||
{ projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 },
|
useCommandPaletteShortcut,
|
||||||
{ projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity },
|
} from "./CommandPalette";
|
||||||
{ 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",
|
|
||||||
];
|
|
||||||
|
|
||||||
function projectIdFromPathname(pathname: string): string | undefined {
|
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);
|
const match = matchPath({ path: pattern, end: false }, pathname);
|
||||||
if (match?.params.projectId) return match.params.projectId;
|
if (match?.params.projectId) return match.params.projectId;
|
||||||
}
|
}
|
||||||
@@ -77,7 +47,7 @@ function projectIdFromPathname(pathname: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workspaceSectionFromPathname(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);
|
const match = matchPath({ path: pattern, end: false }, pathname);
|
||||||
if (match?.params.projectId) return pattern.split("/")[1];
|
if (match?.params.projectId) return pattern.split("/")[1];
|
||||||
}
|
}
|
||||||
@@ -85,11 +55,17 @@ function workspaceSectionFromPathname(pathname: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
|
useThemeSync();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
|
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||||
|
const [chordLeader, setChordLeader] = useState<string | null>(null);
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
|
||||||
const routeProjectId = projectIdFromPathname(location.pathname);
|
const routeProjectId = projectIdFromPathname(location.pathname);
|
||||||
const workspaceSection = workspaceSectionFromPathname(location.pathname);
|
const workspaceSection = workspaceSectionFromPathname(location.pathname);
|
||||||
const { data: projects } = useProjects();
|
const { data: projects } = useProjects();
|
||||||
@@ -99,9 +75,58 @@ export default function AppShell() {
|
|||||||
!projects ||
|
!projects ||
|
||||||
projects.some((project) => project.name === routeProjectId);
|
projects.some((project) => project.name === routeProjectId);
|
||||||
const currentProjectId = routeProjectExists
|
const currentProjectId = routeProjectExists
|
||||||
? routeProjectId ?? fallbackProjectId
|
? (routeProjectId ?? fallbackProjectId)
|
||||||
: 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<string, () => 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<ShortcutGroup[]>(() => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (routeProjectId && projects && !routeProjectExists) {
|
if (routeProjectId && projects && !routeProjectExists) {
|
||||||
const nextPath =
|
const nextPath =
|
||||||
@@ -119,98 +144,362 @@ export default function AppShell() {
|
|||||||
workspaceSection,
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen bg-background text-foreground">
|
<div className="flex min-h-screen bg-background text-foreground">
|
||||||
|
<Sidebar
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
currentProjectId={currentProjectId ?? null}
|
||||||
|
onToggle={() => dispatch(toggleSidebar())}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur-md">
|
||||||
|
<ProjectSwitcher
|
||||||
|
projects={(projects ?? []).map((p) => p.name)}
|
||||||
|
currentProjectId={currentProjectId ?? null}
|
||||||
|
open={projectMenuOpen}
|
||||||
|
onOpenChange={setProjectMenuOpen}
|
||||||
|
onSelect={switchProject}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
className="group flex h-9 max-w-md flex-1 items-center gap-2 rounded-md border border-border bg-background-subtle px-3 text-sm text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground"
|
||||||
|
aria-label={t("nav.openCommandPalette", "Open command palette")}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
{t("nav.searchPlaceholder", "Search pages, projects…")}
|
||||||
|
</span>
|
||||||
|
<kbd className="hidden gap-0.5 rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs text-muted-foreground sm:inline-flex">
|
||||||
|
{modKey} K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<Tooltip content={t("nav.help", "Keyboard shortcuts")} shortcut="?">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("nav.help", "Keyboard shortcuts")}
|
||||||
|
onClick={() => setShortcutsOpen(true)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<ThemeToggle />
|
||||||
|
<div className="ml-2 h-7 w-px bg-border" aria-hidden />
|
||||||
|
<span
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700 dark:bg-brand-900 dark:text-brand-200"
|
||||||
|
title={t("nav.account", "Account")}
|
||||||
|
>
|
||||||
|
ON
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="min-w-0 flex-1 overflow-auto">
|
||||||
|
<ErrorBoundary resetKeys={[location.pathname]}>
|
||||||
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onOpenChange={setPaletteOpen}
|
||||||
|
currentProjectId={currentProjectId ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShortcutsOverlay
|
||||||
|
open={shortcutsOpen}
|
||||||
|
onOpenChange={setShortcutsOpen}
|
||||||
|
groups={shortcutGroups}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChordHint leader={chordLeader} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChordHint({ leader }: { leader: string | null }) {
|
||||||
|
if (!leader) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2 animate-fade-in-up"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg">
|
||||||
|
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs">
|
||||||
|
{leader}
|
||||||
|
</kbd>
|
||||||
|
<span className="text-muted-foreground">…</span>
|
||||||
|
<span className="text-muted-foreground">awaiting follower key</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Sidebar
|
||||||
|
* ========================================================== */
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
sidebarOpen,
|
||||||
|
currentProjectId,
|
||||||
|
onToggle,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
currentProjectId: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r bg-card transition-all duration-200 ease-out",
|
"sticky top-0 flex h-screen flex-col border-r border-border bg-surface transition-[width] duration-fast ease-out-expo",
|
||||||
sidebarOpen ? "w-60" : "w-16",
|
sidebarOpen ? "w-64" : "w-[60px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-14 items-center justify-between px-4 border-b">
|
<div className="flex h-14 items-center gap-2 border-b border-border px-3">
|
||||||
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-brand-600 text-white shadow-sm">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span className="text-sm font-semibold">
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
{t("app.title", "Ontology Builder")}
|
{t("app.title", "Ontology Builder")}
|
||||||
</span>
|
</p>
|
||||||
|
<p className="truncate text-2xs text-muted-foreground">
|
||||||
|
{t("app.subtitle", "AI-powered ontology construction")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => dispatch(toggleSidebar())}
|
onClick={onToggle}
|
||||||
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
|
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
|
||||||
|
className="h-7 w-7"
|
||||||
>
|
>
|
||||||
<Menu className="h-4 w-4" />
|
{sidebarOpen ? (
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto py-2">
|
||||||
|
{NAV_GROUPS.map((group) => (
|
||||||
|
<div key={group.id} className="px-2 pb-2">
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="border-b px-4 py-3">
|
<p className="px-3 pb-1 pt-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
{t(group.labelKey, group.label)}
|
||||||
{t("nav.currentProject", "Current project")}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-sm font-medium">
|
|
||||||
{currentProjectId ??
|
|
||||||
t("nav.noProjectSelected", "Select a project")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
<nav className="flex flex-col gap-1 p-2">
|
{group.items.map((item) => {
|
||||||
{navItems.map(({ to, projectPath, labelKey, label, icon: Icon }) => {
|
|
||||||
const href =
|
const href =
|
||||||
to ??
|
item.to ??
|
||||||
(currentProjectId
|
(item.projectPath && currentProjectId
|
||||||
? `/${projectPath}/${encodeURIComponent(currentProjectId)}`
|
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
|
||||||
: undefined);
|
: undefined);
|
||||||
|
const Icon = item.icon;
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return (
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
<div
|
<div
|
||||||
key={labelKey}
|
className={cn(
|
||||||
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
|
"flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-muted-foreground/40",
|
||||||
|
!sidebarOpen && "justify-center px-2",
|
||||||
|
)}
|
||||||
title={t(
|
title={t(
|
||||||
"nav.selectProjectFirst",
|
"nav.selectProjectFirst",
|
||||||
"Select a project from the dashboard first",
|
"Select a project from the dashboard first",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||||
{sidebarOpen && <span>{t(labelKey, label)}</span>}
|
{sidebarOpen && (
|
||||||
|
<span className="truncate">
|
||||||
|
{t(item.labelKey, item.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
<NavLink
|
<NavLink
|
||||||
key={labelKey}
|
|
||||||
to={href}
|
to={href}
|
||||||
end={href === "/"}
|
end={href === "/"}
|
||||||
|
title={
|
||||||
|
!sidebarOpen ? t(item.labelKey, item.label) : undefined
|
||||||
|
}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
"group relative flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors",
|
||||||
|
!sidebarOpen && "justify-center px-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200"
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-y-1 left-0 w-0.5 rounded-r-full bg-brand-600 dark:bg-brand-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||||
{sidebarOpen && <span>{t(labelKey, label)}</span>}
|
{sidebarOpen && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{t(item.labelKey, item.label)}
|
||||||
|
</span>
|
||||||
|
{item.shortcut && (
|
||||||
|
<kbd className="ml-1 hidden flex-shrink-0 rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs text-muted-foreground group-hover:inline-flex">
|
||||||
|
{item.shortcut}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</ul>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
|
|
||||||
<h1 className="text-sm font-medium text-muted-foreground">
|
|
||||||
{t("app.subtitle", "AI-powered ontology construction")}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<main className="flex-1 overflow-auto">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="border-t border-border p-3 text-2xs text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>v0.2.0</span>
|
||||||
|
<span className="pill-info">beta</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 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 (
|
||||||
|
<div className="relative" ref={(node) => (ref.current = node)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenChange(!open)}
|
||||||
|
className="flex h-9 items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
|
||||||
|
{(currentProjectId ?? "—").slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="max-w-[160px] truncate">
|
||||||
|
{currentProjectId ?? t("nav.noProjectSelected", "Select project")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="absolute left-0 top-full z-40 mt-1.5 w-72 overflow-hidden rounded-lg border border-border bg-popover shadow-lg animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-3 py-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("nav.projects", "Projects")}
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-72 overflow-y-auto py-1">
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("nav.noProjects", "No projects yet.")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{projects.map((name) => (
|
||||||
|
<li key={name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(name)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
name === currentProjectId
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-foreground hover:bg-accent/60",
|
||||||
|
)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={name === currentProjectId}
|
||||||
|
>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
|
||||||
|
{name.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-3 py-2 text-2xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Command className="h-3 w-3" />
|
||||||
|
Press <kbd className="rounded border border-border bg-surface px-1 py-0.5">K</kbd> for palette
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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<CommandItem[]>(() => {
|
||||||
|
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<HTMLElement>(
|
||||||
|
`[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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel="Command palette">
|
||||||
|
<DialogPanel className="mx-auto mt-20 max-w-xl">
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1.5">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No results for “{query}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{grouped.map((section) => (
|
||||||
|
<div key={section.group} className="px-1.5">
|
||||||
|
<div className="px-3 pb-1 pt-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{section.group}
|
||||||
|
</div>
|
||||||
|
{section.items.map((cmd) => {
|
||||||
|
runningIndex += 1;
|
||||||
|
const isActive = runningIndex === activeIndex;
|
||||||
|
const Icon = cmd.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cmd.id}
|
||||||
|
data-cmd-index={runningIndex}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setActiveIndex(runningIndex)}
|
||||||
|
onClick={cmd.onSelect}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate">{cmd.label}</span>
|
||||||
|
{cmd.hint && (
|
||||||
|
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
|
||||||
|
{cmd.hint}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<CornerDownLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-4 py-2 text-2xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5">↑</kbd>
|
||||||
|
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5">↓</kbd>
|
||||||
|
navigate
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5">↵</kbd>
|
||||||
|
select
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>{filtered.length} results</span>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border bg-surface",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-7xl px-6 pb-4 pt-5">
|
||||||
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||||
|
<Breadcrumbs items={breadcrumbs} className="mb-2" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{Icon && (
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h1 className="truncate text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{meta && <div className="flex items-center gap-2">{meta}</div>}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tabs && (
|
||||||
|
<div className="mx-auto max-w-7xl px-6">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto">{tabs}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className={cn("flex items-center text-xs text-muted-foreground", className)}
|
||||||
|
>
|
||||||
|
<ol className="flex flex-wrap items-center gap-1">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const isLast = idx === items.length - 1;
|
||||||
|
return (
|
||||||
|
<li key={idx} className="flex items-center gap-1">
|
||||||
|
{item.to && !isLast ? (
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
className="rounded px-1 hover:text-foreground hover:underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-1",
|
||||||
|
isLast && "font-medium text-foreground",
|
||||||
|
)}
|
||||||
|
aria-current={isLast ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isLast && (
|
||||||
|
<ChevronRight className="h-3 w-3 flex-shrink-0 opacity-60" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ThemeMode, React.ComponentType<{ className?: string }>> = {
|
||||||
|
light: Sun,
|
||||||
|
dark: Moon,
|
||||||
|
system: Monitor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS: Record<ThemeMode, string> = {
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => dispatch(cycleTheme())}
|
||||||
|
aria-label={t("theme.toggle", "Switch theme") + ` (${LABELS[mode]})`}
|
||||||
|
title={`${t("theme.label", "Theme")}: ${LABELS[mode]}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`),
|
||||||
|
);
|
||||||
1054
ontology_platform/web/frontend/src/components/ui/data-table.tsx
Normal file
1054
ontology_platform/web/frontend/src/components/ui/data-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
214
ontology_platform/web/frontend/src/components/ui/dialog.tsx
Normal file
214
ontology_platform/web/frontend/src/components/ui/dialog.tsx
Normal file
@@ -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:
|
||||||
|
*
|
||||||
|
* <Dialog open onOpenChange>
|
||||||
|
* <DialogPanel ariaLabel="…">
|
||||||
|
* <DialogHeader onClose>...</DialogHeader>
|
||||||
|
* <DialogBody>...</DialogBody>
|
||||||
|
* <DialogFooter>...</DialogFooter>
|
||||||
|
* </DialogPanel>
|
||||||
|
* </Dialog>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DialogCtx.Provider
|
||||||
|
value={{ ariaLabel, closeOnOverlayClick, closeOnEscape }}
|
||||||
|
>
|
||||||
|
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
{children}
|
||||||
|
</RadixDialog.Root>
|
||||||
|
</DialogCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogTrigger = RadixDialog.Trigger;
|
||||||
|
export const DialogClose = RadixDialog.Close;
|
||||||
|
|
||||||
|
interface DialogPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Accessible label when the panel has no DialogTitle */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Accessible description id reference */
|
||||||
|
ariaDescribedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogPanel = React.forwardRef<HTMLDivElement, DialogPanelProps>(
|
||||||
|
({ className, children, ariaLabel, ariaDescribedBy, ...props }, ref) => {
|
||||||
|
const ctx = React.useContext(DialogCtx);
|
||||||
|
const label = ariaLabel ?? ctx.ariaLabel;
|
||||||
|
return (
|
||||||
|
<RadixDialog.Portal>
|
||||||
|
<RadixDialog.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||||
|
"data-[state=open]:animate-fade-in",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<RadixDialog.Content
|
||||||
|
ref={ref}
|
||||||
|
aria-label={label}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
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) && (
|
||||||
|
<RadixDialog.Title className="sr-only">{label}</RadixDialog.Title>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</RadixDialog.Content>
|
||||||
|
</RadixDialog.Portal>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
DialogPanel.displayName = "DialogPanel";
|
||||||
|
|
||||||
|
/** Best-effort detection: does the children tree include a <DialogTitle>? */
|
||||||
|
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<HTMLDivElement> & { onClose?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start justify-between gap-4 border-b border-border px-5 py-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
{onClose && (
|
||||||
|
<RadixDialog.Close asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="-mr-2 -mt-1 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</RadixDialog.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogTitle = React.forwardRef<
|
||||||
|
HTMLHeadingElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadixDialog.Title asChild>
|
||||||
|
<h2
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-base font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</RadixDialog.Title>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = "DialogTitle";
|
||||||
|
|
||||||
|
export const DialogDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<RadixDialog.Description asChild>
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-1 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</RadixDialog.Description>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = "DialogDescription";
|
||||||
|
|
||||||
|
export function DialogBody({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex-1 overflow-y-auto px-5 py-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-row-reverse items-center gap-2 border-t border-border px-5 py-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
ontology_platform/web/frontend/src/components/ui/drawer.tsx
Normal file
143
ontology_platform/web/frontend/src/components/ui/drawer.tsx
Normal file
@@ -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<Size, string> = {
|
||||||
|
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 (
|
||||||
|
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<RadixDialog.Portal>
|
||||||
|
<RadixDialog.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||||
|
"data-[state=open]:animate-fade-in",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<RadixDialog.Content
|
||||||
|
aria-labelledby={title ? titleId : undefined}
|
||||||
|
aria-describedby={description ? descId : undefined}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
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) && (
|
||||||
|
<header className="flex items-start justify-between gap-4 border-b border-border px-6 py-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{title && (
|
||||||
|
<RadixDialog.Title asChild>
|
||||||
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className="truncate text-base font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</RadixDialog.Title>
|
||||||
|
)}
|
||||||
|
{!title && (
|
||||||
|
// Radix requires a Title for accessibility; provide a hidden one
|
||||||
|
<RadixDialog.Title className="sr-only">
|
||||||
|
Panel
|
||||||
|
</RadixDialog.Title>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<RadixDialog.Description asChild>
|
||||||
|
<p
|
||||||
|
id={descId}
|
||||||
|
className="mt-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</RadixDialog.Description>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{headerExtra}
|
||||||
|
<RadixDialog.Close asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</RadixDialog.Close>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-border bg-background-subtle px-6 py-3">
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</RadixDialog.Content>
|
||||||
|
</RadixDialog.Portal>
|
||||||
|
</RadixDialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <Button>) */
|
||||||
|
primaryAction?: React.ReactNode;
|
||||||
|
/** Optional secondary action */
|
||||||
|
secondaryAction?: React.ReactNode;
|
||||||
|
/** Style variant — danger for errors, info for empty-by-design */
|
||||||
|
variant?: Variant;
|
||||||
|
/** Compact vertical padding for inline / panel usage */
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_BG: Record<Variant, string> = {
|
||||||
|
default: "bg-muted text-muted-foreground",
|
||||||
|
muted: "bg-muted text-muted-foreground",
|
||||||
|
danger: "bg-danger-subtle text-danger",
|
||||||
|
info: "bg-info-subtle text-info",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
secondaryAction,
|
||||||
|
variant = "default",
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-4 text-center",
|
||||||
|
compact ? "py-8" : "py-16",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-full",
|
||||||
|
ICON_BG[variant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-w-md space-y-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(primaryAction || secondaryAction) && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||||
|
{primaryAction}
|
||||||
|
{secondaryAction}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
ontology_platform/web/frontend/src/components/ui/error-state.tsx
Normal file
150
ontology_platform/web/frontend/src/components/ui/error-state.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { AlertCircle, AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
type Severity = "error" | "warning" | "offline";
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
/** Visual severity */
|
||||||
|
severity?: Severity;
|
||||||
|
/** Short headline, e.g. "Could not load entities" */
|
||||||
|
title: React.ReactNode;
|
||||||
|
/** Longer explanation. Pass an `Error` and the message will be extracted. */
|
||||||
|
description?: React.ReactNode | Error;
|
||||||
|
/** Retry handler — adds a Retry button when provided */
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** Disable the retry button (e.g. during a refetch) */
|
||||||
|
retrying?: boolean;
|
||||||
|
/** Inline vs full block layout */
|
||||||
|
inline?: boolean;
|
||||||
|
/** Compact vertical padding */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Show technical details (stack) in a collapsible block */
|
||||||
|
detail?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON: Record<Severity, React.ComponentType<{ className?: string }>> = {
|
||||||
|
error: AlertCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
offline: WifiOff,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE: Record<
|
||||||
|
Severity,
|
||||||
|
{ icon: string; ring: string; bg: string; text: string }
|
||||||
|
> = {
|
||||||
|
error: {
|
||||||
|
icon: "text-danger",
|
||||||
|
ring: "border-danger-border",
|
||||||
|
bg: "bg-danger-subtle",
|
||||||
|
text: "text-danger",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: "text-warning",
|
||||||
|
ring: "border-warning-border",
|
||||||
|
bg: "bg-warning-subtle",
|
||||||
|
text: "text-warning",
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
icon: "text-muted-foreground",
|
||||||
|
ring: "border-border",
|
||||||
|
bg: "bg-muted",
|
||||||
|
text: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ErrorState({
|
||||||
|
severity = "error",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onRetry,
|
||||||
|
retrying,
|
||||||
|
inline = false,
|
||||||
|
compact = false,
|
||||||
|
detail,
|
||||||
|
className,
|
||||||
|
}: ErrorStateProps) {
|
||||||
|
const Icon = ICON[severity];
|
||||||
|
const tone = TONE[severity];
|
||||||
|
|
||||||
|
const desc =
|
||||||
|
description instanceof Error ? description.message : description;
|
||||||
|
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 rounded-md border px-4 py-3",
|
||||||
|
tone.ring,
|
||||||
|
tone.bg,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("mt-0.5 h-4 w-4 flex-shrink-0", tone.icon)} />
|
||||||
|
<div className="min-w-0 flex-1 text-sm">
|
||||||
|
<div className={cn("font-medium", tone.text)}>{title}</div>
|
||||||
|
{desc && (
|
||||||
|
<div className="mt-0.5 text-muted-foreground">{desc}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn("h-3.5 w-3.5", retrying && "animate-spin")}
|
||||||
|
/>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-4 text-center",
|
||||||
|
compact ? "py-8" : "py-16",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-full",
|
||||||
|
tone.bg,
|
||||||
|
tone.icon,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||||
|
{desc && <p className="text-sm text-muted-foreground">{desc}</p>}
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<Button onClick={onRetry} disabled={retrying} variant="outline">
|
||||||
|
<RefreshCw className={cn("h-4 w-4", retrying && "animate-spin")} />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detail && (
|
||||||
|
<details className="mt-2 w-full max-w-2xl text-left">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
Show technical details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs text-muted-foreground">
|
||||||
|
{detail}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <FilterChips /> */
|
||||||
|
chips?: React.ReactNode;
|
||||||
|
onClearAll?: () => void;
|
||||||
|
className?: string;
|
||||||
|
toolbarClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border bg-surface",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-2 px-3 py-2",
|
||||||
|
toolbarClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{chips && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
|
||||||
|
{chips}
|
||||||
|
{onClearAll && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Search box
|
||||||
|
* ========================================================== */
|
||||||
|
|
||||||
|
export function FilterSearch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Search…",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative min-w-[200px] flex-1", className)}>
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-9 pl-8"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 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 (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
|
||||||
|
hasValue && "border-brand-500 text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{label}:</span>
|
||||||
|
<span className={cn(hasValue ? "font-medium" : "text-muted-foreground")}>
|
||||||
|
{selectedLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="z-40 w-56 overflow-hidden rounded-md border border-border bg-popover shadow-md animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<ul className="max-h-72 overflow-y-auto py-1">
|
||||||
|
{allowEmpty && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
|
||||||
|
!hasValue && "bg-accent/40 font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
{!hasValue && <Check className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isSelected = opt.value === value;
|
||||||
|
return (
|
||||||
|
<li key={opt.value}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
|
||||||
|
isSelected && "bg-accent/40 font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.swatch && (
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: opt.swatch }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate">{opt.label}</span>
|
||||||
|
{opt.hint != null && (
|
||||||
|
<span className="text-2xs text-muted-foreground tabular-nums">
|
||||||
|
{opt.hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 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 (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
|
||||||
|
active && "border-brand-500 text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{label}:</span>
|
||||||
|
<span className={cn(active ? "font-medium" : "text-muted-foreground")}>
|
||||||
|
≥ {format(value)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="z-40 w-64 rounded-md border border-border bg-popover p-3 shadow-md animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium tabular-nums">{format(value)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-2xs text-muted-foreground">
|
||||||
|
<span>{format(min)}</span>
|
||||||
|
<span>{format(max)}</span>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Toggle (checkbox-style chip)
|
||||||
|
* ========================================================== */
|
||||||
|
|
||||||
|
export function FilterToggle({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
|
||||||
|
checked && "border-brand-500",
|
||||||
|
disabled && "cursor-not-allowed opacity-50 hover:bg-surface",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 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 (
|
||||||
|
<span className="text-2xs text-muted-foreground">No active filters</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<span
|
||||||
|
key={chip.id}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-200"
|
||||||
|
>
|
||||||
|
<span className="text-brand-600/70 dark:text-brand-300/70">
|
||||||
|
{chip.label}:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{chip.value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={chip.onClear}
|
||||||
|
className="ml-0.5 -mr-0.5 rounded-full p-0.5 hover:bg-brand-200/60 dark:hover:bg-brand-800/60"
|
||||||
|
aria-label={`Remove ${chip.label} filter`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Refresh / view actions slot (placed on the right of toolbar)
|
||||||
|
* ========================================================== */
|
||||||
|
|
||||||
|
export function FilterActions({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="ml-auto flex items-center gap-1">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export Button for convenience when composing inline
|
||||||
|
export { Button };
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-3 text-center text-muted-foreground",
|
||||||
|
compact ? "py-6" : "py-16",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-brand-500" />
|
||||||
|
{message && <span className="text-sm">{message}</span>}
|
||||||
|
<span className="sr-only">Loading</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "skeleton") {
|
||||||
|
return <Skeleton className={cn("h-24 w-full", className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "list") {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)} aria-busy="true">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// table
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-lg border border-border",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border bg-background-subtle px-3 py-2">
|
||||||
|
<Skeleton className="h-3.5 w-32" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="grid grid-cols-4 gap-3 border-b border-border px-3 py-3 last:border-0"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-3.5 w-3/4" />
|
||||||
|
<Skeleton className="h-3.5 w-2/3" />
|
||||||
|
<Skeleton className="h-3.5 w-1/2" />
|
||||||
|
<Skeleton className="h-3.5 w-3/5" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <kbd> chips.
|
||||||
|
*/
|
||||||
|
export function ShortcutsOverlay({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
groups,
|
||||||
|
title = "Keyboard shortcuts",
|
||||||
|
description = "Press the key combination to trigger the action.",
|
||||||
|
}: ShortcutsOverlayProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel={title}>
|
||||||
|
<DialogPanel className="mx-auto mt-20 max-w-2xl">
|
||||||
|
<DialogHeader onClose={() => onOpenChange(false)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
|
||||||
|
<Keyboard className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.title}>
|
||||||
|
<h3 className="mb-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.keys + item.label}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background-subtle px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<ShortcutChips keys={item.keys} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogPanel>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutChips({ keys }: { keys: string }) {
|
||||||
|
// chord (space) → sequence; modifier (+) → group
|
||||||
|
const trimmed = keys.trim();
|
||||||
|
if (trimmed.includes(" ")) {
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{parts.map((p, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<Kbd>{p}</Kbd>
|
||||||
|
{i < parts.length - 1 && (
|
||||||
|
<span className="text-2xs text-muted-foreground">then</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trimmed.includes("+")) {
|
||||||
|
const parts = trimmed.split("+").map((p) => p.trim());
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{parts.map((p, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<Kbd>{prettifyKey(p)}</Kbd>
|
||||||
|
{i < parts.length - 1 && (
|
||||||
|
<span className="text-2xs text-muted-foreground">+</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Kbd>{prettifyKey(trimmed)}</Kbd>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<kbd className="inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs font-medium text-foreground shadow-xs">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
ontology_platform/web/frontend/src/components/ui/tooltip.tsx
Normal file
59
ontology_platform/web/frontend/src/components/ui/tooltip.tsx
Normal file
@@ -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 <TooltipProvider> 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 (
|
||||||
|
<TooltipPrimitive.Root delayDuration={delayMs}>
|
||||||
|
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
side={side}
|
||||||
|
align={align}
|
||||||
|
sideOffset={6}
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex items-center gap-2 rounded-md border border-border bg-popover px-2.5 py-1.5",
|
||||||
|
"text-2xs text-popover-foreground shadow-md",
|
||||||
|
"animate-fade-in",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{content}</span>
|
||||||
|
{shortcut && (
|
||||||
|
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||||
|
{shortcut}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
<TooltipPrimitive.Arrow className="fill-popover stroke-border" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</TooltipPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
173
ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -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<string, Handler>,
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
30
ontology_platform/web/frontend/src/hooks/useTheme.ts
Normal file
30
ontology_platform/web/frontend/src/hooks/useTheme.ts
Normal file
@@ -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 `<html>` 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]);
|
||||||
|
}
|
||||||
@@ -8,17 +8,23 @@ import "./i18n";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import store from "./stores";
|
import store from "./stores";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
||||||
<BrowserRouter basename="/static/">
|
<BrowserRouter basename="/static/">
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -5,28 +6,38 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Globe2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
Settings2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { useProject } from "@/hooks/useProjects";
|
||||||
import { useCreateSource, useDeleteSource } from "@/hooks/useSources";
|
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 = [
|
const SOURCE_TYPES = [
|
||||||
"official",
|
"official",
|
||||||
"review",
|
"review",
|
||||||
@@ -62,6 +73,15 @@ const sourceSchema = z.object({
|
|||||||
|
|
||||||
type SourceFormValues = z.infer<typeof sourceSchema>;
|
type SourceFormValues = z.infer<typeof sourceSchema>;
|
||||||
|
|
||||||
|
const TYPE_TONE: Record<string, string> = {
|
||||||
|
official: "pill-info",
|
||||||
|
review: "pill-warning",
|
||||||
|
blog: "pill-muted",
|
||||||
|
news: "pill-info",
|
||||||
|
community: "pill-muted",
|
||||||
|
unknown: "pill-muted",
|
||||||
|
};
|
||||||
|
|
||||||
export default function ConfigureSourcesPage() {
|
export default function ConfigureSourcesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -79,6 +99,8 @@ export default function ConfigureSourcesPage() {
|
|||||||
const createSource = useCreateSource(projectName);
|
const createSource = useCreateSource(projectName);
|
||||||
const deleteSource = useDeleteSource(projectName);
|
const deleteSource = useDeleteSource(projectName);
|
||||||
|
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -96,6 +118,11 @@ export default function ConfigureSourcesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
reset();
|
||||||
|
setAddOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const onAddSource = async (values: SourceFormValues) => {
|
const onAddSource = async (values: SourceFormValues) => {
|
||||||
try {
|
try {
|
||||||
await createSource.mutateAsync({
|
await createSource.mutateAsync({
|
||||||
@@ -108,6 +135,7 @@ export default function ConfigureSourcesPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
reset();
|
reset();
|
||||||
|
setAddOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("sources.addFailed", "추가 실패: {{msg}}", {
|
t("sources.addFailed", "추가 실패: {{msg}}", {
|
||||||
@@ -143,179 +171,241 @@ export default function ConfigureSourcesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const sources = project?.sources ?? [];
|
||||||
<div className="mx-auto max-w-5xl px-6 py-10">
|
|
||||||
<div className="mb-6 flex items-center gap-3">
|
const columns: ColumnDef<SourceRow, any>[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: t("sources.name", "이름"),
|
||||||
|
accessorFn: (row) => row.name,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
|
||||||
|
<Globe2 className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{row.original.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: 240,
|
||||||
|
enablePinning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
header: t("sources.type", "타입"),
|
||||||
|
accessorFn: (row) => row.type,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className={TYPE_TONE[row.original.type] ?? "pill-muted"}>
|
||||||
|
{row.original.type}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "base_url",
|
||||||
|
header: "Base URL",
|
||||||
|
accessorFn: (row) => row.base_url ?? "",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.base_url ? (
|
||||||
|
<a
|
||||||
|
href={row.original.base_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
<span className="max-w-[260px] truncate">
|
||||||
|
{row.original.base_url}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground/60">—</span>
|
||||||
|
),
|
||||||
|
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 ? (
|
||||||
|
<span className="text-xs text-muted-foreground/60">—</span>
|
||||||
|
) : (
|
||||||
|
<TrustBar value={n} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 140,
|
||||||
|
meta: { align: "right" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rate",
|
||||||
|
header: t("sources.rateLimit", "rate/분"),
|
||||||
|
accessorFn: (row) => row.rate_limit_per_minute ?? 0,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs">
|
||||||
|
{row.original.rate_limit_per_minute ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 110,
|
||||||
|
meta: { align: "right" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
enableResizing: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate("/")}
|
onClick={(e) => {
|
||||||
aria-label={t("common.back", "이전")}
|
e.stopPropagation();
|
||||||
|
onDelete(row.original.name);
|
||||||
|
}}
|
||||||
|
disabled={deleteSource.isPending}
|
||||||
|
aria-label={t("sources.delete", "삭제")}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<Trash2 className="h-4 w-4 text-danger" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
|
||||||
{t("sources.title", "참고 소스 설정")}
|
|
||||||
</h1>
|
|
||||||
{project && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{project.name}{" "}
|
|
||||||
<span className="capitalize text-muted-foreground/70">
|
|
||||||
· {project.domain}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
size: 60,
|
||||||
|
meta: { align: "right" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
icon={Settings2}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: t("nav.dashboard", "Dashboard"), to: "/" },
|
||||||
|
{ label: projectName, to: `/sources/${projectName}` },
|
||||||
|
{ label: t("sources.title", "Source Explorer") },
|
||||||
|
]}
|
||||||
|
title={t("sources.title", "참고 소스 설정")}
|
||||||
|
description={
|
||||||
|
project &&
|
||||||
|
`${project.name} · ${project.domain} · ${sources.length} ${t("sources.count", "sources")}`
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("sources.add", "소스 추가")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/crawl/${projectName}`)}
|
onClick={() => navigate(`/crawl/${projectName}`)}
|
||||||
disabled={!project || (project.sources?.length ?? 0) === 0}
|
disabled={!project || sources.length === 0}
|
||||||
>
|
>
|
||||||
{t("sources.next", "크롤 진행")}
|
{t("sources.next", "크롤 진행")}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||||
{isError && (
|
{isError && (
|
||||||
<Card className="mb-6 border-destructive">
|
<ErrorState
|
||||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
className="mb-4"
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
inline
|
||||||
<AlertCircle className="h-4 w-4" />
|
severity="error"
|
||||||
<span>{(error as Error).message}</span>
|
title={t("sources.loadFailedTitle", "소스를 불러오지 못했습니다")}
|
||||||
</div>
|
description={error}
|
||||||
<Button
|
onRetry={() => refetch()}
|
||||||
variant="outline"
|
/>
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
>
|
|
||||||
{t("common.retry", "다시 시도")}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
<DataTable<SourceRow>
|
||||||
<section>
|
tableId="sources"
|
||||||
<Card>
|
columns={columns}
|
||||||
<CardHeader>
|
data={sources}
|
||||||
<CardTitle>{t("sources.listTitle", "등록된 소스")}</CardTitle>
|
getRowId={(row) => row.id}
|
||||||
<CardDescription>
|
loading={isLoading}
|
||||||
{t(
|
loadingRows={4}
|
||||||
"sources.listDesc",
|
height={560}
|
||||||
"프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
|
empty={
|
||||||
)}
|
<EmptyState
|
||||||
</CardDescription>
|
icon={Globe2}
|
||||||
</CardHeader>
|
title={t("sources.emptyTitle", "등록된 소스가 없습니다")}
|
||||||
<CardContent>
|
description={t(
|
||||||
{isLoading && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-16" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project && project.sources.length === 0 && (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"sources.empty",
|
"sources.empty",
|
||||||
"아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
|
"첫 참고 사이트를 추가해 크롤을 시작하세요.",
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
primaryAction={
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("sources.add", "소스 추가")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{project && project.sources.length > 0 && (
|
<Drawer
|
||||||
<ul className="divide-y">
|
open={addOpen}
|
||||||
{project.sources.map((s) => (
|
onOpenChange={setAddOpen}
|
||||||
<li
|
size="md"
|
||||||
key={s.id}
|
title={t("sources.addTitle", "소스 추가")}
|
||||||
className="flex items-center justify-between gap-4 py-3"
|
description={t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
|
||||||
>
|
footer={
|
||||||
<div className="min-w-0 flex-1">
|
<>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{s.name}</span>
|
|
||||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
|
||||||
{s.type}
|
|
||||||
</span>
|
|
||||||
{typeof s.trust_level === "number" && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("sources.trust", "신뢰도")}{" "}
|
|
||||||
{s.trust_level.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{s.base_url && (
|
|
||||||
<a
|
|
||||||
href={s.base_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
{s.base_url}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
type="button"
|
||||||
onClick={() => onDelete(s.name)}
|
onClick={() => setAddOpen(false)}
|
||||||
disabled={deleteSource.isPending}
|
|
||||||
aria-label={t("sources.delete", "삭제")}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
{t("common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
<Button
|
||||||
))}
|
type="submit"
|
||||||
</ul>
|
form="add-source-form"
|
||||||
|
disabled={isSubmitting || createSource.isPending}
|
||||||
|
>
|
||||||
|
{createSource.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
{t("sources.add", "소스 추가")}
|
||||||
</Card>
|
</Button>
|
||||||
</section>
|
</>
|
||||||
|
}
|
||||||
<aside>
|
>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("sources.addTitle", "소스 추가")}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form
|
<form
|
||||||
|
id="add-source-form"
|
||||||
onSubmit={handleSubmit(onAddSource)}
|
onSubmit={handleSubmit(onAddSource)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="src_name">
|
<Label htmlFor="src_name">{t("sources.name", "이름")}</Label>
|
||||||
{t("sources.name", "이름")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="src_name"
|
id="src_name"
|
||||||
placeholder="official_brand_site"
|
placeholder="official_brand_site"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-danger">{errors.name.message}</p>
|
||||||
{errors.name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="src_type">
|
<Label htmlFor="src_type">{t("sources.type", "타입")}</Label>
|
||||||
{t("sources.type", "타입")}
|
|
||||||
</Label>
|
|
||||||
<select
|
<select
|
||||||
id="src_type"
|
id="src_type"
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
{...register("type")}
|
{...register("type")}
|
||||||
>
|
>
|
||||||
{SOURCE_TYPES.map((t) => (
|
{SOURCE_TYPES.map((opt) => (
|
||||||
<option key={t} value={t}>
|
<option key={opt} value={opt}>
|
||||||
{t}
|
{opt}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -332,9 +422,7 @@ export default function ConfigureSourcesPage() {
|
|||||||
{...register("base_url")}
|
{...register("base_url")}
|
||||||
/>
|
/>
|
||||||
{errors.base_url && (
|
{errors.base_url && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-danger">{errors.base_url.message}</p>
|
||||||
{errors.base_url.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -352,7 +440,7 @@ export default function ConfigureSourcesPage() {
|
|||||||
{...register("trust_level", { valueAsNumber: true })}
|
{...register("trust_level", { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
{errors.trust_level && (
|
{errors.trust_level && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-danger">
|
||||||
{errors.trust_level.message}
|
{errors.trust_level.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -371,7 +459,7 @@ export default function ConfigureSourcesPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.rate_limit_per_minute && (
|
{errors.rate_limit_per_minute && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-danger">
|
||||||
{errors.rate_limit_per_minute.message}
|
{errors.rate_limit_per_minute.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -384,28 +472,26 @@ export default function ConfigureSourcesPage() {
|
|||||||
className="h-4 w-4 rounded border-input"
|
className="h-4 w-4 rounded border-input"
|
||||||
{...register("respect_robots_txt")}
|
{...register("respect_robots_txt")}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>{t("sources.respectRobots", "robots.txt 준수")}</span>
|
||||||
{t("sources.respectRobots", "robots.txt 준수")}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isSubmitting || createSource.isPending}
|
|
||||||
>
|
|
||||||
{createSource.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("sources.add", "소스 추가")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</Drawer>
|
||||||
</Card>
|
</>
|
||||||
</aside>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={`h-full ${tone}`} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{value.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,45 @@ import { useMemo, useState } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeft,
|
|
||||||
CircleDot,
|
CircleDot,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Keyboard,
|
||||||
|
Layers,
|
||||||
|
Map as MapIcon,
|
||||||
Network,
|
Network,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
SlidersHorizontal,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Select } from "@/components/ui/select";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { useGraphNeighborhood } from "@/hooks/usePlatform";
|
||||||
|
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||||
import { useProject } from "@/hooks/useProjects";
|
import { useProject } from "@/hooks/useProjects";
|
||||||
import { GraphEdge, GraphNode } from "@/lib/api/platform";
|
import { GraphEdge, GraphNode } from "@/lib/api/platform";
|
||||||
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
||||||
@@ -39,6 +56,9 @@ const COLORS = [
|
|||||||
"#dc2626",
|
"#dc2626",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const VIEW_W = 860;
|
||||||
|
const VIEW_H = 600;
|
||||||
|
|
||||||
interface VisualNode {
|
interface VisualNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -150,8 +170,8 @@ function buildGraph(
|
|||||||
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
|
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerX = 430;
|
const centerX = VIEW_W / 2;
|
||||||
const centerY = 300;
|
const centerY = VIEW_H / 2;
|
||||||
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
|
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
|
||||||
visualNodes.forEach((node, index) => {
|
visualNodes.forEach((node, index) => {
|
||||||
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
|
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
|
||||||
@@ -179,6 +199,12 @@ export default function GraphViewPage() {
|
|||||||
const [entityType, setEntityType] = useState("");
|
const [entityType, setEntityType] = useState("");
|
||||||
const [minConfidence, setMinConfidence] = useState(0);
|
const [minConfidence, setMinConfidence] = useState(0);
|
||||||
const [selected, setSelected] = useState<VisualNode | VisualEdge | null>(null);
|
const [selected, setSelected] = useState<VisualNode | VisualEdge | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
|
||||||
|
const [showLegend, setShowLegend] = useState(true);
|
||||||
|
const [showMinimap, setShowMinimap] = useState(true);
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
|
||||||
const graph = useGraphNeighborhood(projectName, {
|
const graph = useGraphNeighborhood(projectName, {
|
||||||
includeCandidates: includeCandidates && statusScope === "validated_claim",
|
includeCandidates: includeCandidates && statusScope === "validated_claim",
|
||||||
limit: 300,
|
limit: 300,
|
||||||
@@ -212,153 +238,301 @@ export default function GraphViewPage() {
|
|||||||
search,
|
search,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const averageConfidence = visual.edges.length
|
|
||||||
? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) /
|
// Filter out types the user toggled off in the legend
|
||||||
visual.edges.length
|
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<string, number>();
|
||||||
|
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;
|
: 0;
|
||||||
|
|
||||||
|
const selectNode = (node: VisualNode | VisualEdge) => {
|
||||||
|
setSelected(node);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
<>
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<PageHeader
|
||||||
<Button
|
icon={Network}
|
||||||
variant="ghost"
|
breadcrumbs={[
|
||||||
size="icon"
|
{ label: "Dashboard", to: "/" },
|
||||||
onClick={() => navigate(`/quality/${projectName}`)}
|
{ label: projectName, to: `/sources/${projectName}` },
|
||||||
aria-label="Back"
|
{ label: "Graph View" },
|
||||||
>
|
]}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
title="Graph View"
|
||||||
</Button>
|
description={`${project?.name ?? projectName} — relation graph, candidates, and edge evidence.`}
|
||||||
<div className="flex-1">
|
actions={
|
||||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
<Tooltip content="Refresh graph data" shortcut="R">
|
||||||
<Network className="h-6 w-6 text-primary" />
|
|
||||||
Graph View
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{project?.name ?? projectName} relation graph, candidates, and edge evidence.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => graph.refetch()}>
|
<Button variant="outline" onClick={() => graph.refetch()}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||||
{graph.isError && (
|
{graph.isError && (
|
||||||
<Card className="mb-6 border-destructive">
|
<div className="mb-4 flex items-center gap-2 rounded-md border border-danger-border bg-danger-subtle px-4 py-3 text-sm text-danger">
|
||||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
{(graph.error as Error).message}
|
{(graph.error as Error).message}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4 grid gap-3 xl:grid-cols-[1fr_180px_180px_180px_220px]">
|
{/* Filter bar — unified FilterPanel */}
|
||||||
<div className="relative">
|
{(() => {
|
||||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
const statusLabels: Record<string, string> = {
|
||||||
<Input
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setSearch("");
|
||||||
|
setEntityType("");
|
||||||
|
setPredicate("");
|
||||||
|
setStatusScope("validated_claim");
|
||||||
|
setMinConfidence(0);
|
||||||
|
setIncludeCandidates(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterPanel
|
||||||
|
className="mb-4"
|
||||||
|
chips={chips.length > 0 ? <FilterChips chips={chips} /> : undefined}
|
||||||
|
onClearAll={chips.length > 0 ? clearAll : undefined}
|
||||||
|
>
|
||||||
|
<FilterSearch
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={setSearch}
|
||||||
className="pl-9"
|
placeholder="Search node or type…"
|
||||||
placeholder="Search node or type"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<FilterSelect
|
||||||
<Select
|
label="Type"
|
||||||
value={entityType}
|
value={entityType}
|
||||||
onChange={(event) => setEntityType(event.target.value)}
|
onChange={setEntityType}
|
||||||
>
|
placeholder="All types"
|
||||||
<option value="">All types</option>
|
options={visual.types.map((t) => ({
|
||||||
{visual.types.map((type) => (
|
value: t,
|
||||||
<option key={type} value={type}>
|
label: t,
|
||||||
{type}
|
hint: typeCounts.get(t),
|
||||||
</option>
|
}))}
|
||||||
))}
|
/>
|
||||||
</Select>
|
<FilterSelect
|
||||||
<Select
|
label="Predicate"
|
||||||
value={predicate}
|
value={predicate}
|
||||||
onChange={(event) => setPredicate(event.target.value)}
|
onChange={setPredicate}
|
||||||
>
|
placeholder="All predicates"
|
||||||
<option value="">All predicates</option>
|
options={predicates.map((p) => ({ value: p, label: p }))}
|
||||||
{predicates.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={statusScope}
|
|
||||||
onChange={(event) => setStatusScope(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="validated_claim">Validated</option>
|
|
||||||
<option value="candidate_claim">Candidate</option>
|
|
||||||
<option value="rule_candidate">Rule candidate</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="all">All statuses</option>
|
|
||||||
</Select>
|
|
||||||
<label className="flex items-center gap-2 rounded-md border px-3 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={includeCandidates}
|
|
||||||
disabled={statusScope !== "validated_claim"}
|
|
||||||
onChange={(event) => setIncludeCandidates(event.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
Blend candidates
|
<FilterSelect
|
||||||
</label>
|
label="Status"
|
||||||
</div>
|
value={statusScope === "validated_claim" ? "" : statusScope}
|
||||||
|
onChange={(v) => setStatusScope(v || "validated_claim")}
|
||||||
<div className="mb-6 rounded-md border px-4 py-3">
|
placeholder="Validated"
|
||||||
<div className="mb-2 flex items-center justify-between text-sm">
|
allowEmpty
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
options={Object.entries(statusLabels)
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
.filter(([k]) => k !== "validated_claim")
|
||||||
Minimum confidence
|
.map(([k, v]) => ({ value: k, label: v }))}
|
||||||
</span>
|
/>
|
||||||
<span>{formatPercent(minConfidence)}</span>
|
<FilterRange
|
||||||
</div>
|
label="Min confidence"
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={minConfidence}
|
value={minConfidence}
|
||||||
onChange={(event) => setMinConfidence(Number(event.target.value))}
|
onChange={setMinConfidence}
|
||||||
className="w-full"
|
format={(v) => `${Math.round(v * 100)}%`}
|
||||||
/>
|
/>
|
||||||
|
<FilterToggle
|
||||||
|
label="Blend candidates"
|
||||||
|
checked={includeCandidates}
|
||||||
|
onChange={setIncludeCandidates}
|
||||||
|
disabled={statusScope !== "validated_claim"}
|
||||||
|
/>
|
||||||
|
</FilterPanel>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="relative mb-6 overflow-hidden rounded-lg border border-border bg-surface">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
Ontology Network
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{visibleNodes.length} nodes / {visibleEdges.length} edges shown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip
|
||||||
|
content={showLegend ? "Hide legend" : "Show legend"}
|
||||||
|
shortcut="L"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle legend"
|
||||||
|
onClick={() => setShowLegend((v) => !v)}
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={showMinimap ? "Hide minimap" : "Show minimap"}
|
||||||
|
shortcut="M"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle minimap"
|
||||||
|
onClick={() => setShowMinimap((v) => !v)}
|
||||||
|
>
|
||||||
|
<MapIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Show keyboard shortcuts" shortcut="?">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
onClick={() => setShortcutsOpen(true)}
|
||||||
|
>
|
||||||
|
<Keyboard className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
<div className="relative">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Ontology Network</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{visual.nodes.length} nodes / {visual.edges.length} edges shown
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{visual.types.slice(0, 8).map((type) => (
|
|
||||||
<span key={type} className="flex items-center gap-1 text-xs">
|
|
||||||
<span
|
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
|
||||||
style={{ backgroundColor: nodeColor(type, visual.types) }}
|
|
||||||
/>
|
|
||||||
{type}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{graph.isLoading ? (
|
{graph.isLoading ? (
|
||||||
<Skeleton className="h-[640px]" />
|
<Skeleton className="h-[640px]" />
|
||||||
) : visual.nodes.length === 0 ? (
|
) : visibleNodes.length === 0 ? (
|
||||||
<div className="flex h-[500px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
<EmptyState
|
||||||
<GitBranch className="h-12 w-12 opacity-50" />
|
icon={GitBranch}
|
||||||
No graph is available for the current filters.
|
title="No graph to show"
|
||||||
</div>
|
description="No nodes match the current filters."
|
||||||
|
variant="muted"
|
||||||
|
compact
|
||||||
|
className="h-[500px]"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 860 600"
|
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||||
className="h-[640px] w-full rounded-md border bg-secondary/20"
|
className="block h-[640px] w-full bg-background-subtle text-muted-foreground"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Ontology graph"
|
aria-label="Ontology graph"
|
||||||
>
|
>
|
||||||
@@ -372,91 +546,201 @@ export default function GraphViewPage() {
|
|||||||
orient="auto"
|
orient="auto"
|
||||||
markerUnits="strokeWidth"
|
markerUnits="strokeWidth"
|
||||||
>
|
>
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
|
<path d="M0,0 L0,6 L9,3 z" fill="currentColor" />
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
{visual.edges.map((edge) => {
|
{visibleEdges.map((edge) => {
|
||||||
const { source, target } = edgeEndpoint(edge, visual.nodes);
|
const { source, target } = edgeEndpoint(edge, visibleNodes);
|
||||||
if (!source || !target) return null;
|
if (!source || !target) return null;
|
||||||
const midX = (source.x + target.x) / 2;
|
const midX = (source.x + target.x) / 2;
|
||||||
const midY = (source.y + target.y) / 2;
|
const midY = (source.y + target.y) / 2;
|
||||||
|
const isSelected =
|
||||||
|
selected && "predicate" in selected && selected.id === edge.id;
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={edge.id}
|
key={edge.id}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => setSelected(edge)}
|
onClick={() => selectNode(edge)}
|
||||||
>
|
>
|
||||||
<line
|
<line
|
||||||
x1={source.x}
|
x1={source.x}
|
||||||
y1={source.y}
|
y1={source.y}
|
||||||
x2={target.x}
|
x2={target.x}
|
||||||
y2={target.y}
|
y2={target.y}
|
||||||
stroke="#64748b"
|
stroke="currentColor"
|
||||||
strokeWidth={1 + edge.confidence * 3}
|
strokeWidth={1 + edge.confidence * 3}
|
||||||
strokeOpacity="0.65"
|
strokeOpacity={isSelected ? 1 : 0.55}
|
||||||
markerEnd="url(#arrow)"
|
markerEnd="url(#arrow)"
|
||||||
|
className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={midX}
|
x={midX}
|
||||||
y={midY}
|
y={midY}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-slate-600 text-[10px]"
|
className="fill-muted-foreground text-[10px]"
|
||||||
|
paintOrder="stroke"
|
||||||
|
stroke="hsl(var(--background))"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
{edge.predicate}
|
{edge.predicate}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visual.nodes.map((node) => (
|
{visibleNodes.map((node) => {
|
||||||
|
const isSelected =
|
||||||
|
selected && "id" in selected && selected.id === node.id;
|
||||||
|
return (
|
||||||
<g
|
<g
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => setSelected(node)}
|
onClick={() => selectNode(node)}
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
cx={node.x}
|
cx={node.x}
|
||||||
cy={node.y}
|
cy={node.y}
|
||||||
r={node.radius}
|
r={node.radius}
|
||||||
fill={node.color}
|
fill={node.color}
|
||||||
stroke={
|
className={
|
||||||
selected && "id" in selected && selected.id === node.id
|
isSelected
|
||||||
? "#111827"
|
? "stroke-foreground"
|
||||||
: "#ffffff"
|
: "stroke-background"
|
||||||
}
|
}
|
||||||
strokeWidth="2"
|
strokeWidth={isSelected ? 3 : 2}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={node.x}
|
x={node.x}
|
||||||
y={node.y + node.radius + 14}
|
y={node.y + node.radius + 14}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-slate-800 text-[11px] font-medium"
|
className="fill-foreground text-[11px] font-medium"
|
||||||
|
paintOrder="stroke"
|
||||||
|
stroke="hsl(var(--background))"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
{node.name.length > 24
|
{node.name.length > 24
|
||||||
? `${node.name.slice(0, 24)}...`
|
? `${node.name.slice(0, 24)}...`
|
||||||
: node.name}
|
: node.name}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend overlay (top-right) */}
|
||||||
|
{showLegend && (
|
||||||
|
<LegendPanel
|
||||||
|
types={visual.types}
|
||||||
|
typeCounts={typeCounts}
|
||||||
|
hiddenTypes={hiddenTypes}
|
||||||
|
onToggle={toggleType}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Minimap overlay (bottom-right) */}
|
||||||
|
{showMinimap && visibleNodes.length > 0 && (
|
||||||
|
<Minimap nodes={visibleNodes} edges={visibleEdges} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom stats row */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Graph Health</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<HealthRow label="Visible nodes" value={visibleNodes.length} />
|
||||||
|
<HealthRow label="Visible edges" value={visibleEdges.length} />
|
||||||
|
<HealthRow
|
||||||
|
label="Avg confidence"
|
||||||
|
value={
|
||||||
|
visibleEdges.length ? formatPercent(averageConfidence) : "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Progress value={averageConfidence * 100} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<aside className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Selection</CardTitle>
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
<CardDescription>Click a node or edge to inspect it.</CardDescription>
|
<CircleDot className="h-4 w-4" />
|
||||||
|
Predicate Counts
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!selected && (
|
<ul className="grid gap-1.5 sm:grid-cols-2">
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
{predicates.map((name) => {
|
||||||
Nothing selected.
|
const count = (graph.data?.edges ?? []).filter(
|
||||||
</p>
|
(edge) => edge.predicate === name,
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={name}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
<span className="font-medium tabular-nums">{count}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{predicates.length === 0 && (
|
||||||
|
<li className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No predicates found.
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node / Edge detail drawer */}
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen && !!selected}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
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 && (
|
||||||
|
<Badge variant={"predicate" in selected ? "secondary" : "outline"}>
|
||||||
|
{"predicate" in selected ? selected.predicate : selected.type}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setSelected(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
{selected && "predicate" in selected && (
|
{selected && "predicate" in selected && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Badge variant="secondary">{selected.predicate}</Badge>
|
|
||||||
<Detail label="Claim" value={`#${selected.id}`} />
|
<Detail label="Claim" value={`#${selected.id}`} />
|
||||||
<Detail label="Status" value={selected.raw.status} />
|
<Detail label="Status" value={selected.raw.status} />
|
||||||
<Detail
|
<Detail
|
||||||
@@ -479,78 +763,184 @@ export default function GraphViewPage() {
|
|||||||
)}
|
)}
|
||||||
{selected && !("predicate" in selected) && (
|
{selected && !("predicate" in selected) && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Badge variant="outline">{selected.type}</Badge>
|
|
||||||
<Detail label="Name" value={selected.name} />
|
<Detail label="Name" value={selected.name} />
|
||||||
<Detail label="Source" value={selected.source} />
|
<Detail label="Source" value={selected.source} />
|
||||||
<Detail label="Canonical" value={selected.raw?.canonical_name} />
|
<Detail
|
||||||
|
label="Canonical"
|
||||||
|
value={selected.raw?.canonical_name}
|
||||||
|
/>
|
||||||
<Detail
|
<Detail
|
||||||
label="Metadata"
|
label="Metadata"
|
||||||
value={humanizeValue(selected.raw?.metadata)}
|
value={humanizeValue(selected.raw?.metadata)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate(`/editor/${projectName}`)}
|
||||||
|
>
|
||||||
|
Open in Entity Manager
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</Drawer>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<ShortcutsOverlay
|
||||||
<CardHeader>
|
open={shortcutsOpen}
|
||||||
<CardTitle>Graph Health</CardTitle>
|
onOpenChange={setShortcutsOpen}
|
||||||
</CardHeader>
|
groups={pageShortcutGroups}
|
||||||
<CardContent className="space-y-3">
|
title="Graph View shortcuts"
|
||||||
<HealthRow label="Visible nodes" value={visual.nodes.length} />
|
|
||||||
<HealthRow label="Visible edges" value={visual.edges.length} />
|
|
||||||
<HealthRow
|
|
||||||
label="Avg confidence"
|
|
||||||
value={
|
|
||||||
visual.edges.length ? formatPercent(averageConfidence) : "-"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Progress value={averageConfidence * 100} />
|
</>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
}
|
||||||
|
|
||||||
<Card>
|
/* ============================================================
|
||||||
<CardHeader>
|
* Legend Panel — overlay, toggle visibility per type
|
||||||
<CardTitle className="flex items-center gap-2">
|
* ========================================================== */
|
||||||
<CircleDot className="h-5 w-5" />
|
function LegendPanel({
|
||||||
Predicate Counts
|
types,
|
||||||
</CardTitle>
|
typeCounts,
|
||||||
</CardHeader>
|
hiddenTypes,
|
||||||
<CardContent>
|
onToggle,
|
||||||
<ul className="space-y-2 text-sm">
|
}: {
|
||||||
{predicates.map((name) => {
|
types: string[];
|
||||||
const count = (graph.data?.edges ?? []).filter(
|
typeCounts: Map<string, number>;
|
||||||
(edge) => edge.predicate === name,
|
hiddenTypes: Set<string>;
|
||||||
).length;
|
onToggle: (type: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<li
|
<div className="absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
|
||||||
key={name}
|
<div className="border-b border-border px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
className="flex items-center justify-between rounded-md border px-3 py-2"
|
Legend
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-64 overflow-y-auto py-1">
|
||||||
|
{types.map((type) => {
|
||||||
|
const hidden = hiddenTypes.has(type);
|
||||||
|
const count = typeCounts.get(type) ?? 0;
|
||||||
|
return (
|
||||||
|
<li key={type}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(type)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
|
||||||
|
aria-pressed={!hidden}
|
||||||
>
|
>
|
||||||
<span>{name}</span>
|
<span
|
||||||
<span className="font-medium">{count}</span>
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: nodeColor(type, types),
|
||||||
|
opacity: hidden ? 0.3 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
hidden
|
||||||
|
? "flex-1 truncate text-muted-foreground/60 line-through"
|
||||||
|
: "flex-1 truncate text-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xs text-muted-foreground tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
{hidden ? (
|
||||||
|
<EyeOff className="h-3 w-3 text-muted-foreground/60" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{predicates.length === 0 && (
|
|
||||||
<li className="py-4 text-center text-muted-foreground">
|
|
||||||
No predicates found.
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 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 (
|
||||||
|
<div className="absolute bottom-3 right-3 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-2.5 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span>Minimap</span>
|
||||||
|
<span className="font-mono normal-case">
|
||||||
|
{nodes.length}·{edges.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
width={W}
|
||||||
|
height={H}
|
||||||
|
className="block text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{edges.map((e) => {
|
||||||
|
const s = nodes.find((n) => n.id === e.source);
|
||||||
|
const t = nodes.find((n) => n.id === e.target);
|
||||||
|
if (!s || !t) return null;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={e.id}
|
||||||
|
x1={tx(s.x)}
|
||||||
|
y1={ty(s.y)}
|
||||||
|
x2={tx(t.x)}
|
||||||
|
y2={ty(t.y)}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity="0.35"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<circle
|
||||||
|
key={n.id}
|
||||||
|
cx={tx(n.x)}
|
||||||
|
cy={ty(n.y)}
|
||||||
|
r={1.8}
|
||||||
|
fill={n.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Detail({ label, value }: { label: string; value: unknown }) {
|
function Detail({ label, value }: { label: string; value: unknown }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border px-3 py-2 text-sm">
|
<div className="rounded-md border border-border bg-background-subtle px-3 py-2 text-sm">
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
<div className="text-2xs uppercase tracking-wider text-muted-foreground">
|
||||||
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-words font-medium text-foreground">
|
||||||
|
{humanizeValue(value)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -559,7 +949,7 @@ function HealthRow({ label, value }: { label: string; value: number | string })
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground">{label}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium tabular-nums">
|
||||||
{typeof value === "number" ? value.toLocaleString() : value}
|
{typeof value === "number" ? value.toLocaleString() : value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -168,23 +170,23 @@ export default function ReviewPage() {
|
|||||||
<Metric label="Rejected" value={counts.rejected} />
|
<Metric label="Rejected" value={counts.rejected} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_420px]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
<Card>
|
<Card className="min-w-0">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<CardTitle>Review Queue</CardTitle>
|
<CardTitle>Review Queue</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
|
신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
className="w-56 pl-9"
|
className="w-48 pl-9 sm:w-56"
|
||||||
placeholder="Search claims"
|
placeholder="Search claims"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +195,7 @@ export default function ReviewPage() {
|
|||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(event) => setStatusFilter(event.target.value)}
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
className="w-40 pl-9"
|
className="w-32 pl-9 sm:w-40"
|
||||||
>
|
>
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
<option value="candidate">Candidate</option>
|
<option value="candidate">Candidate</option>
|
||||||
@@ -218,51 +220,81 @@ export default function ReviewPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredClaims.map((claim) => (
|
{filteredClaims.map((claim) => {
|
||||||
|
const isSelected = selectedClaim?.id === claim.id;
|
||||||
|
return (
|
||||||
<article
|
<article
|
||||||
key={claim.id}
|
key={claim.id}
|
||||||
className="rounded-md border bg-background p-4"
|
className={cn(
|
||||||
|
"rounded-md border bg-surface transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-500 ring-1 ring-brand-500/30"
|
||||||
|
: "border-border hover:border-border-strong",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedId(claim.id)}
|
onClick={() => setSelectedId(claim.id)}
|
||||||
className="min-w-0 flex-1 text-left"
|
className="block w-full px-4 pt-4 pb-3 text-left"
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||||
<Badge variant={statusVariant(claim.status)}>
|
<Badge variant={statusVariant(claim.status)}>
|
||||||
{reviewLabel(claim.status)}
|
{reviewLabel(claim.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary">{claim.predicate}</Badge>
|
<Badge variant="secondary">{claim.predicate}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-2xs text-muted-foreground">
|
||||||
{confidenceBucket(claim)}
|
{confidenceBucket(claim)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-baseline gap-1.5 text-sm">
|
||||||
<span className="font-medium">
|
<span className="break-all font-medium text-foreground">
|
||||||
{humanizeValue(claim.subject)}
|
{humanizeValue(claim.subject)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">-></span>
|
<ArrowRight className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
<span className="font-medium">{claimObject(claim)}</span>
|
<span className="break-all font-medium text-foreground">
|
||||||
|
{claimObject(claim)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-2xs text-muted-foreground">
|
||||||
<span>Confidence {formatPercent(claim.confidence)}</span>
|
|
||||||
<span>Source {humanizeValue(claim.source)}</span>
|
|
||||||
<span>
|
<span>
|
||||||
Evidence {claim.evidence_text ? "yes" : "missing"}
|
Confidence{" "}
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatPercent(claim.confidence)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Method {humanizeValue(claim.extraction_method)}
|
Source{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{humanizeValue(claim.source)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Evidence{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
claim.evidence_text
|
||||||
|
? "font-medium text-success"
|
||||||
|
: "font-medium text-warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{claim.evidence_text ? "yes" : "missing"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Method{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{humanizeValue(claim.extraction_method)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedId(claim.id)}
|
onClick={() => setSelectedId(claim.id)}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -271,7 +303,7 @@ export default function ReviewPage() {
|
|||||||
onClick={() => applyStatus(claim, "validated_claim")}
|
onClick={() => applyStatus(claim, "validated_claim")}
|
||||||
disabled={updateStatus.isPending}
|
disabled={updateStatus.isPending}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -280,18 +312,18 @@ export default function ReviewPage() {
|
|||||||
onClick={() => applyStatus(claim, "rejected")}
|
onClick={() => applyStatus(claim, "rejected")}
|
||||||
disabled={updateStatus.isPending}
|
disabled={updateStatus.isPending}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-3.5 w-3.5" />
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
<aside className="xl:sticky xl:top-4 xl:self-start">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Claim Detail</CardTitle>
|
<CardTitle>Claim Detail</CardTitle>
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "op.theme";
|
||||||
|
|
||||||
|
function readStoredTheme(): ThemeMode {
|
||||||
|
if (typeof window === "undefined") return "system";
|
||||||
|
try {
|
||||||
|
const v = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (v === "light" || v === "dark" || v === "system") return v;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistTheme(mode: ThemeMode) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
currentStep: number; // 0: dashboard, 1: onboard, 2: sources, 3: crawl, 4: review
|
currentStep: number; // 0: dashboard, 1: onboard, 2: sources, 3: crawl, 4: review
|
||||||
selectedProjectId: string | null;
|
selectedProjectId: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
theme: ThemeMode;
|
||||||
notification: {
|
notification: {
|
||||||
type: "success" | "error" | "info" | "warning" | null;
|
type: "success" | "error" | "info" | "warning" | null;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -16,6 +41,7 @@ const initialState: UIState = {
|
|||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
selectedProjectId: null,
|
selectedProjectId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
theme: readStoredTheme(),
|
||||||
notification: {
|
notification: {
|
||||||
type: null,
|
type: null,
|
||||||
message: "",
|
message: "",
|
||||||
@@ -50,6 +76,20 @@ const uiSlice = createSlice({
|
|||||||
clearNotification: (state) => {
|
clearNotification: (state) => {
|
||||||
state.notification = { type: null, message: "" };
|
state.notification = { type: null, message: "" };
|
||||||
},
|
},
|
||||||
|
setTheme: (state, action: PayloadAction<ThemeMode>) => {
|
||||||
|
state.theme = action.payload;
|
||||||
|
persistTheme(action.payload);
|
||||||
|
},
|
||||||
|
cycleTheme: (state) => {
|
||||||
|
const next: ThemeMode =
|
||||||
|
state.theme === "light"
|
||||||
|
? "dark"
|
||||||
|
: state.theme === "dark"
|
||||||
|
? "system"
|
||||||
|
: "light";
|
||||||
|
state.theme = next;
|
||||||
|
persistTheme(next);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +100,8 @@ export const {
|
|||||||
setLoading,
|
setLoading,
|
||||||
showNotification,
|
showNotification,
|
||||||
clearNotification,
|
clearNotification,
|
||||||
|
setTheme,
|
||||||
|
cycleTheme,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
|
|
||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
|
|||||||
@@ -2,50 +2,180 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Design Tokens — Ontology Platform
|
||||||
|
* Two layers:
|
||||||
|
* 1. Primitive tokens (brand-*, chart-*) — raw palette
|
||||||
|
* 2. Semantic tokens (background, surface, primary, ring, success, ...)
|
||||||
|
* reference primitives; only semantic tokens should be used in components.
|
||||||
|
* Values are HSL triplets (no `hsl()` wrapper) for Tailwind opacity modifiers.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
/* — Brand scale (deep indigo, tuned for data-heavy surfaces) — */
|
||||||
--foreground: 222.2 84% 4.9%;
|
--brand-50: 220 100% 97%;
|
||||||
|
--brand-100: 220 95% 93%;
|
||||||
|
--brand-200: 221 92% 86%;
|
||||||
|
--brand-300: 222 88% 76%;
|
||||||
|
--brand-400: 224 84% 64%;
|
||||||
|
--brand-500: 226 78% 55%;
|
||||||
|
--brand-600: 228 74% 47%;
|
||||||
|
--brand-700: 229 70% 39%;
|
||||||
|
--brand-800: 230 64% 32%;
|
||||||
|
--brand-900: 231 58% 24%;
|
||||||
|
--brand-950: 232 60% 14%;
|
||||||
|
|
||||||
|
/* — Chart palette (qualitative, colorblind-safer ordering) — */
|
||||||
|
--chart-1: 226 78% 55%;
|
||||||
|
--chart-2: 175 70% 41%;
|
||||||
|
--chart-3: 32 95% 56%;
|
||||||
|
--chart-4: 280 68% 60%;
|
||||||
|
--chart-5: 340 80% 58%;
|
||||||
|
--chart-6: 142 64% 42%;
|
||||||
|
--chart-7: 200 90% 48%;
|
||||||
|
--chart-8: 12 78% 56%;
|
||||||
|
--chart-9: 55 88% 52%;
|
||||||
|
--chart-10: 260 70% 62%;
|
||||||
|
--chart-11: 165 60% 38%;
|
||||||
|
--chart-12: 300 60% 52%;
|
||||||
|
|
||||||
|
/* — Semantic surfaces (light) — */
|
||||||
|
--background: 210 25% 99%;
|
||||||
|
--background-subtle: 220 24% 96%;
|
||||||
|
--surface: 0 0% 100%;
|
||||||
|
--surface-raised: 0 0% 100%;
|
||||||
|
--foreground: 222 47% 11%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222 47% 11%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222 47% 11%;
|
||||||
--muted: 221.2 63.6% 97%;
|
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted: 220 20% 96%;
|
||||||
--accent: 221.2 83.2% 53.3%;
|
--muted-foreground: 220 12% 44%;
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--primary: 226 78% 55%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--ring: 221.2 83.2% 53.3%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--primary: 222.2 47.6% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96%;
|
|
||||||
--secondary-foreground: 222.2 47.6% 11.2%;
|
--secondary: 220 20% 94%;
|
||||||
|
--secondary-foreground: 222 47% 16%;
|
||||||
|
|
||||||
|
--accent: 220 20% 94%;
|
||||||
|
--accent-foreground: 222 47% 16%;
|
||||||
|
|
||||||
|
--border: 220 16% 90%;
|
||||||
|
--border-strong: 220 14% 82%;
|
||||||
|
--input: 220 16% 90%;
|
||||||
|
--ring: 226 78% 55%;
|
||||||
|
|
||||||
|
/* — Semantic states (light) — */
|
||||||
|
--success: 142 62% 40%;
|
||||||
|
--success-foreground: 140 80% 98%;
|
||||||
|
--success-subtle: 142 60% 95%;
|
||||||
|
--success-border: 142 50% 78%;
|
||||||
|
|
||||||
|
--warning: 32 92% 48%;
|
||||||
|
--warning-foreground: 30 90% 98%;
|
||||||
|
--warning-subtle: 40 95% 94%;
|
||||||
|
--warning-border: 36 88% 76%;
|
||||||
|
|
||||||
|
--danger: 0 75% 52%;
|
||||||
|
--danger-foreground: 0 80% 98%;
|
||||||
|
--danger-subtle: 0 80% 96%;
|
||||||
|
--danger-border: 0 70% 82%;
|
||||||
|
|
||||||
|
--info: 210 85% 50%;
|
||||||
|
--info-foreground: 210 90% 98%;
|
||||||
|
--info-subtle: 210 90% 95%;
|
||||||
|
--info-border: 210 80% 80%;
|
||||||
|
|
||||||
|
/* — Geometry — */
|
||||||
|
--radius: 0.625rem;
|
||||||
|
|
||||||
|
/* — Elevation (light) — uses cool-tinted shadows for crispness — */
|
||||||
|
--shadow-xs: 0 1px 2px 0 hsl(220 30% 12% / 0.04);
|
||||||
|
--shadow-sm: 0 1px 2px 0 hsl(220 30% 12% / 0.05),
|
||||||
|
0 1px 3px 0 hsl(220 30% 12% / 0.06);
|
||||||
|
--shadow-md: 0 2px 4px -1px hsl(220 30% 12% / 0.06),
|
||||||
|
0 4px 8px -2px hsl(220 30% 12% / 0.08);
|
||||||
|
--shadow-lg: 0 4px 8px -2px hsl(220 30% 12% / 0.08),
|
||||||
|
0 12px 24px -6px hsl(220 30% 12% / 0.12);
|
||||||
|
--shadow-xl: 0 8px 16px -4px hsl(220 30% 12% / 0.10),
|
||||||
|
0 24px 48px -12px hsl(220 30% 12% / 0.18);
|
||||||
|
--shadow-2xl: 0 32px 64px -16px hsl(220 30% 12% / 0.24);
|
||||||
|
|
||||||
|
/* — Typography (font families) — */
|
||||||
|
--font-sans: "Pretendard Variable", "Pretendard", "Inter",
|
||||||
|
ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||||
|
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||||
|
--font-display: "Inter", "Pretendard Variable", "Pretendard",
|
||||||
|
ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace,
|
||||||
|
SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* — Brand scale stays primitive; semantic tokens re-point — */
|
||||||
--foreground: 210 40% 98%;
|
--background: 224 30% 7%;
|
||||||
--card: 222.2 84% 4.9%;
|
--background-subtle: 224 28% 10%;
|
||||||
--card-foreground: 210 40% 98%;
|
--surface: 224 28% 10%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--surface-raised: 224 26% 13%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--foreground: 210 30% 96%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--card: 224 26% 11%;
|
||||||
--accent: 217.2 91.2% 59.8%;
|
--card-foreground: 210 30% 96%;
|
||||||
--accent-foreground: 222.2 47.6% 11.2%;
|
--popover: 224 26% 13%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--popover-foreground: 210 30% 96%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--muted: 224 22% 16%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--muted-foreground: 220 14% 64%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
|
||||||
--primary: 210 40% 98%;
|
--primary: 224 84% 64%;
|
||||||
--primary-foreground: 222.2 47.6% 11.2%;
|
--primary-foreground: 226 80% 10%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary: 224 22% 18%;
|
||||||
|
--secondary-foreground: 210 30% 96%;
|
||||||
|
|
||||||
|
--accent: 224 22% 20%;
|
||||||
|
--accent-foreground: 210 30% 96%;
|
||||||
|
|
||||||
|
--border: 224 20% 20%;
|
||||||
|
--border-strong: 224 18% 28%;
|
||||||
|
--input: 224 20% 20%;
|
||||||
|
--ring: 224 84% 64%;
|
||||||
|
|
||||||
|
--success: 142 55% 50%;
|
||||||
|
--success-foreground: 142 80% 8%;
|
||||||
|
--success-subtle: 142 40% 16%;
|
||||||
|
--success-border: 142 40% 28%;
|
||||||
|
|
||||||
|
--warning: 36 92% 58%;
|
||||||
|
--warning-foreground: 30 90% 10%;
|
||||||
|
--warning-subtle: 36 50% 18%;
|
||||||
|
--warning-border: 36 50% 30%;
|
||||||
|
|
||||||
|
--danger: 0 72% 60%;
|
||||||
|
--danger-foreground: 0 80% 98%;
|
||||||
|
--danger-subtle: 0 50% 20%;
|
||||||
|
--danger-border: 0 50% 32%;
|
||||||
|
|
||||||
|
--info: 210 90% 62%;
|
||||||
|
--info-foreground: 210 90% 10%;
|
||||||
|
--info-subtle: 210 50% 18%;
|
||||||
|
--info-border: 210 50% 30%;
|
||||||
|
|
||||||
|
/* shadows on dark are softer and rely on borders + glow */
|
||||||
|
--shadow-xs: 0 1px 2px 0 hsl(0 0% 0% / 0.30);
|
||||||
|
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.32),
|
||||||
|
0 1px 3px 0 hsl(0 0% 0% / 0.30);
|
||||||
|
--shadow-md: 0 2px 4px -1px hsl(0 0% 0% / 0.36),
|
||||||
|
0 4px 8px -2px hsl(0 0% 0% / 0.40);
|
||||||
|
--shadow-lg: 0 4px 8px -2px hsl(0 0% 0% / 0.44),
|
||||||
|
0 12px 24px -6px hsl(0 0% 0% / 0.48);
|
||||||
|
--shadow-xl: 0 8px 16px -4px hsl(0 0% 0% / 0.50),
|
||||||
|
0 24px 48px -12px hsl(0 0% 0% / 0.55);
|
||||||
|
--shadow-2xl: 0 32px 64px -16px hsl(0 0% 0% / 0.65);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +184,92 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-feature-settings: "cv11", "ss01", "ss03";
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground font-sans text-base;
|
||||||
|
font-synthesis-weight: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring — consistent across all interactive elements */
|
||||||
|
:where(button, a, [role="button"], input, select, textarea, [tabindex]):not(
|
||||||
|
[tabindex="-1"]
|
||||||
|
):focus-visible {
|
||||||
|
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background-color: hsl(var(--brand-500) / 0.25);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars (Webkit) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--border-strong));
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--muted-foreground) / 0.6);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings default to display font with tight tracking */
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
@apply font-heading tracking-tight text-foreground;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Numeric tabular figures for tables/metrics */
|
||||||
|
.tabular-nums,
|
||||||
|
table,
|
||||||
|
[data-tabular] {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Surface helpers — use instead of bg-card/bg-background ad-hoc */
|
||||||
|
.surface-1 {
|
||||||
|
@apply bg-surface border border-border;
|
||||||
|
}
|
||||||
|
.surface-2 {
|
||||||
|
@apply bg-surface-raised border border-border shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status pill — semantic colored chip */
|
||||||
|
.pill {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs font-medium;
|
||||||
|
}
|
||||||
|
.pill-success { @apply pill bg-success-subtle text-success border border-success-border; }
|
||||||
|
.pill-warning { @apply pill bg-warning-subtle text-warning border border-warning-border; }
|
||||||
|
.pill-danger { @apply pill bg-danger-subtle text-danger border border-danger-border; }
|
||||||
|
.pill-info { @apply pill bg-info-subtle text-info border border-info-border; }
|
||||||
|
.pill-muted { @apply pill bg-muted text-muted-foreground border border-border; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
darkMode: "class",
|
||||||
"./index.html",
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
"./src/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1rem",
|
||||||
|
screens: { "2xl": "1440px" },
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
|
"border-strong": "hsl(var(--border-strong))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
|
"background-subtle": "hsl(var(--background-subtle))",
|
||||||
|
surface: "hsl(var(--surface))",
|
||||||
|
"surface-raised": "hsl(var(--surface-raised))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground))",
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary))",
|
||||||
@@ -20,10 +27,6 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
},
|
},
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: "hsl(var(--muted))",
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
@@ -32,13 +35,153 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--accent))",
|
DEFAULT: "hsl(var(--accent))",
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
},
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
// Brand scale (indigo-cyan blend tuned for analytics products)
|
||||||
|
brand: {
|
||||||
|
50: "hsl(var(--brand-50))",
|
||||||
|
100: "hsl(var(--brand-100))",
|
||||||
|
200: "hsl(var(--brand-200))",
|
||||||
|
300: "hsl(var(--brand-300))",
|
||||||
|
400: "hsl(var(--brand-400))",
|
||||||
|
500: "hsl(var(--brand-500))",
|
||||||
|
600: "hsl(var(--brand-600))",
|
||||||
|
700: "hsl(var(--brand-700))",
|
||||||
|
800: "hsl(var(--brand-800))",
|
||||||
|
900: "hsl(var(--brand-900))",
|
||||||
|
950: "hsl(var(--brand-950))",
|
||||||
|
},
|
||||||
|
// Semantic states (each has bg/border/foreground)
|
||||||
|
success: {
|
||||||
|
DEFAULT: "hsl(var(--success))",
|
||||||
|
foreground: "hsl(var(--success-foreground))",
|
||||||
|
subtle: "hsl(var(--success-subtle))",
|
||||||
|
border: "hsl(var(--success-border))",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "hsl(var(--warning))",
|
||||||
|
foreground: "hsl(var(--warning-foreground))",
|
||||||
|
subtle: "hsl(var(--warning-subtle))",
|
||||||
|
border: "hsl(var(--warning-border))",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
DEFAULT: "hsl(var(--danger))",
|
||||||
|
foreground: "hsl(var(--danger-foreground))",
|
||||||
|
subtle: "hsl(var(--danger-subtle))",
|
||||||
|
border: "hsl(var(--danger-border))",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: "hsl(var(--info))",
|
||||||
|
foreground: "hsl(var(--info-foreground))",
|
||||||
|
subtle: "hsl(var(--info-subtle))",
|
||||||
|
border: "hsl(var(--info-border))",
|
||||||
|
},
|
||||||
|
// alias for legacy code paths
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--danger))",
|
||||||
|
foreground: "hsl(var(--danger-foreground))",
|
||||||
|
},
|
||||||
|
// Chart palette — qualitative, 12 hues
|
||||||
|
chart: {
|
||||||
|
1: "hsl(var(--chart-1))",
|
||||||
|
2: "hsl(var(--chart-2))",
|
||||||
|
3: "hsl(var(--chart-3))",
|
||||||
|
4: "hsl(var(--chart-4))",
|
||||||
|
5: "hsl(var(--chart-5))",
|
||||||
|
6: "hsl(var(--chart-6))",
|
||||||
|
7: "hsl(var(--chart-7))",
|
||||||
|
8: "hsl(var(--chart-8))",
|
||||||
|
9: "hsl(var(--chart-9))",
|
||||||
|
10: "hsl(var(--chart-10))",
|
||||||
|
11: "hsl(var(--chart-11))",
|
||||||
|
12: "hsl(var(--chart-12))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
xs: "calc(var(--radius) - 6px)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
lg: "var(--radius)",
|
||||||
|
xl: "calc(var(--radius) + 4px)",
|
||||||
|
"2xl": "calc(var(--radius) + 8px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)"],
|
||||||
|
mono: ["var(--font-mono)"],
|
||||||
|
heading: ["var(--font-display)"],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
// Type ramp tuned for dense enterprise UI (line-height as second value)
|
||||||
|
"2xs": ["0.6875rem", { lineHeight: "1rem", letterSpacing: "0.01em" }],
|
||||||
|
xs: ["0.75rem", { lineHeight: "1.125rem" }],
|
||||||
|
sm: ["0.8125rem", { lineHeight: "1.25rem" }],
|
||||||
|
base: ["0.875rem", { lineHeight: "1.375rem" }],
|
||||||
|
md: ["0.9375rem", { lineHeight: "1.5rem" }],
|
||||||
|
lg: ["1rem", { lineHeight: "1.5rem" }],
|
||||||
|
xl: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||||
|
"2xl": ["1.375rem", { lineHeight: "1.875rem", letterSpacing: "-0.01em" }],
|
||||||
|
"3xl": ["1.75rem", { lineHeight: "2.125rem", letterSpacing: "-0.015em" }],
|
||||||
|
"4xl": ["2.25rem", { lineHeight: "2.625rem", letterSpacing: "-0.02em" }],
|
||||||
|
display: ["3rem", { lineHeight: "3.25rem", letterSpacing: "-0.025em", fontWeight: "600" }],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
// density-aware row heights
|
||||||
|
"row-compact": "32px",
|
||||||
|
"row-cozy": "40px",
|
||||||
|
"row-comfortable": "48px",
|
||||||
|
header: "56px",
|
||||||
|
"header-sm": "48px",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
xs: "var(--shadow-xs)",
|
||||||
|
sm: "var(--shadow-sm)",
|
||||||
|
md: "var(--shadow-md)",
|
||||||
|
lg: "var(--shadow-lg)",
|
||||||
|
xl: "var(--shadow-xl)",
|
||||||
|
"2xl": "var(--shadow-2xl)",
|
||||||
|
"focus-ring": "0 0 0 2px hsl(var(--background)), 0 0 0 4px hsl(var(--ring))",
|
||||||
|
"inner-border": "inset 0 0 0 1px hsl(var(--border))",
|
||||||
|
},
|
||||||
|
transitionTimingFunction: {
|
||||||
|
"out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
"in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)",
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
instant: "80ms",
|
||||||
|
fast: "120ms",
|
||||||
|
DEFAULT: "200ms",
|
||||||
|
slow: "320ms",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"fade-in": {
|
||||||
|
from: { opacity: "0" },
|
||||||
|
to: { opacity: "1" },
|
||||||
|
},
|
||||||
|
"fade-in-up": {
|
||||||
|
from: { opacity: "0", transform: "translateY(4px)" },
|
||||||
|
to: { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
|
"slide-in-right": {
|
||||||
|
from: { transform: "translateX(8px)", opacity: "0" },
|
||||||
|
to: { transform: "translateX(0)", opacity: "1" },
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
"100%": { transform: "translateX(100%)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"fade-in": "fade-in 200ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
"fade-in-up": "fade-in-up 240ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
"slide-in-right": "slide-in-right 200ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
shimmer: "shimmer 1.6s linear infinite",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user