This commit is contained in:
LASTA_DEV01\lasta
2026-05-20 18:43:08 +09:00
parent 33584dd631
commit 651c47d698
30 changed files with 6222 additions and 793 deletions

Binary file not shown.

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "온톨로지 빌더",

View File

@@ -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>
);
}
}

View File

@@ -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>
); );
} }

View File

@@ -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 &ldquo;{query}&rdquo;
</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]);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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`),
);

File diff suppressed because it is too large Load Diff

View 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}
/>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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();
};
}, []);
}

View 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]);
}

View File

@@ -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>
); );

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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">-&gt;</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>

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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: [],
} };