Spaces:
Sleeping
Sleeping
initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +6 -0
- codebookly/.gitignore +24 -0
- codebookly/README.md +73 -0
- codebookly/eslint.config.js +23 -0
- codebookly/index.html +12 -0
- codebookly/package-lock.json +0 -0
- codebookly/package.json +34 -0
- codebookly/public/codebookly-icon.svg +1 -0
- codebookly/public/icons.svg +24 -0
- codebookly/src/App.css +112 -0
- codebookly/src/App.tsx +79 -0
- codebookly/src/assets/codebookly-icon.webp +0 -0
- codebookly/src/assets/codebookly.jpg +0 -0
- codebookly/src/assets/hero.png +0 -0
- codebookly/src/assets/react.svg +1 -0
- codebookly/src/assets/vite.svg +1 -0
- codebookly/src/branding.ts +6 -0
- codebookly/src/features/codes/components/CodeCard.tsx +89 -0
- codebookly/src/features/codes/components/CodeFullContextModal.tsx +75 -0
- codebookly/src/features/codes/components/CodeListSection.tsx +124 -0
- codebookly/src/features/codes/components/EnrichedCodeContent.tsx +107 -0
- codebookly/src/features/codes/components/SelectionActionBar.tsx +75 -0
- codebookly/src/features/codes/hooks/useCodeListSelection.ts +77 -0
- codebookly/src/features/codes/hooks/useEnrichedCode.ts +57 -0
- codebookly/src/features/codes/utils/exportCodesJson.ts +19 -0
- codebookly/src/features/codes/utils/exportEnrichedJson.ts +31 -0
- codebookly/src/features/codes/utils/fetchEnrichedForExport.ts +49 -0
- codebookly/src/features/codes/utils/singleCodeToEnriched.ts +18 -0
- codebookly/src/features/definitions/components/DefinitionRow.tsx +34 -0
- codebookly/src/features/definitions/components/DefinitionsExplorer.tsx +57 -0
- codebookly/src/features/definitions/components/DefinitionsSearchForm.tsx +77 -0
- codebookly/src/features/definitions/components/DefinitionsTableSection.tsx +102 -0
- codebookly/src/features/definitions/hooks/useDefinitionsBrowse.ts +102 -0
- codebookly/src/features/definitions/services/definitionsBrowse.ts +18 -0
- codebookly/src/features/layout/SectionChapterBanner.tsx +33 -0
- codebookly/src/features/layout/SideBar.tsx +246 -0
- codebookly/src/features/layout/SidebarAccordion.tsx +63 -0
- codebookly/src/features/layout/sidebarTokens.ts +9 -0
- codebookly/src/hooks/useCodebookApp.ts +249 -0
- codebookly/src/index.css +34 -0
- codebookly/src/main.tsx +22 -0
- codebookly/src/services/apiClient.ts +7 -0
- codebookly/src/services/apiService.ts +10 -0
- codebookly/src/services/codeApi.ts +74 -0
- codebookly/src/services/definitionsApi.ts +40 -0
- codebookly/src/types/code.d.ts +0 -0
- codebookly/src/types/codebook.ts +75 -0
- codebookly/src/types/constants.ts +7 -0
- codebookly/src/types/definitions.ts +14 -0
- codebookly/src/types/navbar.ts +27 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
frontend/node_modules/
|
codebookly/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
codebookly/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
codebookly/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
codebookly/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>codebookly</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
codebookly/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
codebookly/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "codebookly",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@tailwindcss/vite": "^4.2.2",
|
| 14 |
+
"axios": "^1.14.0",
|
| 15 |
+
"lucide-react": "^1.7.0",
|
| 16 |
+
"react": "^19.2.4",
|
| 17 |
+
"react-dom": "^19.2.4",
|
| 18 |
+
"tailwindcss": "^4.2.2"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@eslint/js": "^9.39.4",
|
| 22 |
+
"@types/node": "^24.12.0",
|
| 23 |
+
"@types/react": "^19.2.14",
|
| 24 |
+
"@types/react-dom": "^19.2.3",
|
| 25 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 26 |
+
"eslint": "^9.39.4",
|
| 27 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 28 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 29 |
+
"globals": "^17.4.0",
|
| 30 |
+
"typescript": "~5.9.3",
|
| 31 |
+
"typescript-eslint": "^8.57.0",
|
| 32 |
+
"vite": "^8.0.1"
|
| 33 |
+
}
|
| 34 |
+
}
|
codebookly/public/codebookly-icon.svg
ADDED
|
|
codebookly/public/icons.svg
ADDED
|
|
codebookly/src/App.css
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--primary: #3b82f6;
|
| 3 |
+
--text-main: #ececec;
|
| 4 |
+
--text-muted: #94a3b8;
|
| 5 |
+
--bg-card: #1e1e1e;
|
| 6 |
+
--border: #334155;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.code-card {
|
| 10 |
+
background: var(--bg-card);
|
| 11 |
+
border: 1px solid var(--border);
|
| 12 |
+
border-radius: 8px;
|
| 13 |
+
padding: 1.5rem;
|
| 14 |
+
margin-bottom: 1.25rem;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.code-card:hover {
|
| 18 |
+
border-color: var(--primary);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.section-path {
|
| 22 |
+
display: block;
|
| 23 |
+
font-size: 0.75rem;
|
| 24 |
+
text-transform: uppercase;
|
| 25 |
+
color: var(--primary);
|
| 26 |
+
letter-spacing: 0.05em;
|
| 27 |
+
margin-bottom: 0.25rem;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* App.css */
|
| 31 |
+
|
| 32 |
+
.main-content-wrapper {
|
| 33 |
+
width: 1126px;
|
| 34 |
+
max-width: 100%;
|
| 35 |
+
margin: auto;
|
| 36 |
+
text-align: center;
|
| 37 |
+
min-height: 100svh;
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: column;
|
| 40 |
+
box-sizing: border-box;
|
| 41 |
+
/* Ensure it has a background if the root background is different */
|
| 42 |
+
/* background-color: #0f172a; */
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.code-number {
|
| 46 |
+
font-size: 1.25rem;
|
| 47 |
+
font-weight: 800;
|
| 48 |
+
margin: 0;
|
| 49 |
+
color: var(--text-main);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.code-title {
|
| 53 |
+
font-size: 1rem;
|
| 54 |
+
font-weight: 600;
|
| 55 |
+
color: var(--text-muted);
|
| 56 |
+
margin-top: 0.25rem;
|
| 57 |
+
margin-bottom: 1rem;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.code-content {
|
| 61 |
+
line-height: 1.6;
|
| 62 |
+
font-size: 0.95rem;
|
| 63 |
+
color: var(--text-main);
|
| 64 |
+
margin-bottom: 1.5rem;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.card-footer {
|
| 68 |
+
display: flex;
|
| 69 |
+
gap: 12px;
|
| 70 |
+
align-items: center;
|
| 71 |
+
border-top: 1px solid var(--border);
|
| 72 |
+
padding-top: 1rem;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.badge {
|
| 76 |
+
background: #334155;
|
| 77 |
+
padding: 2px 8px;
|
| 78 |
+
border-radius: 4px;
|
| 79 |
+
font-size: 0.7rem;
|
| 80 |
+
font-weight: bold;
|
| 81 |
+
text-transform: uppercase;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.metadata {
|
| 85 |
+
font-size: 0.8rem;
|
| 86 |
+
color: var(--text-muted);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* Target the scrollable area in the Sidebar */
|
| 90 |
+
nav.flex-1 {
|
| 91 |
+
/* Firefox */
|
| 92 |
+
scrollbar-width: thin;
|
| 93 |
+
scrollbar-color: var(--border) transparent;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* Chrome, Edge, and Safari */
|
| 97 |
+
nav.flex-1::-webkit-scrollbar {
|
| 98 |
+
width: 5px; /* Adjust this for thickness */
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
nav.flex-1::-webkit-scrollbar-track {
|
| 102 |
+
background: transparent; /* Keeps it clean */
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
nav.flex-1::-webkit-scrollbar-thumb {
|
| 106 |
+
background-color: var(--border); /* Matches your site borders */
|
| 107 |
+
border-radius: 20px; /* Makes it rounded */
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
nav.flex-1::-webkit-scrollbar-thumb:hover {
|
| 111 |
+
background-color: var(--accent); /* Optional: turns accent color on hover */
|
| 112 |
+
}
|
codebookly/src/App.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "./App.css";
|
| 2 |
+
// import { CODEBOOKLY_ICON_SRC } from "./branding";
|
| 3 |
+
import { Library } from "lucide-react";
|
| 4 |
+
import { CodeListSection } from "./features/codes/components/CodeListSection";
|
| 5 |
+
import { DefinitionsExplorer } from "./features/definitions/components/DefinitionsExplorer";
|
| 6 |
+
import { SectionChapterBanner } from "./features/layout/SectionChapterBanner";
|
| 7 |
+
import SideBar from "./features/layout/SideBar";
|
| 8 |
+
import { useCodebookApp } from "./hooks/useCodebookApp";
|
| 9 |
+
|
| 10 |
+
function App() {
|
| 11 |
+
const {
|
| 12 |
+
mainView,
|
| 13 |
+
setMainView,
|
| 14 |
+
codes,
|
| 15 |
+
codesAreaLoading,
|
| 16 |
+
sectionChapterMeta,
|
| 17 |
+
designationMeta,
|
| 18 |
+
agencyMeta,
|
| 19 |
+
sections,
|
| 20 |
+
chapters,
|
| 21 |
+
designations,
|
| 22 |
+
agencies,
|
| 23 |
+
handleSectionSelect,
|
| 24 |
+
handleChapterSelect,
|
| 25 |
+
handleDesignationSelect,
|
| 26 |
+
handleAgencySelect,
|
| 27 |
+
} = useCodebookApp();
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div className="flex min-h-screen bg-[var(--bg)]">
|
| 31 |
+
<SideBar
|
| 32 |
+
sections={sections}
|
| 33 |
+
chapters={chapters}
|
| 34 |
+
designations={designations}
|
| 35 |
+
agencies={agencies}
|
| 36 |
+
onSectionSelect={handleSectionSelect}
|
| 37 |
+
onChapterSelect={handleChapterSelect}
|
| 38 |
+
onDesignationSelect={handleDesignationSelect}
|
| 39 |
+
onAgencySelect={handleAgencySelect}
|
| 40 |
+
onDefinitionsSelect={() => setMainView("definitions")}
|
| 41 |
+
definitionsActive={mainView === "definitions"}
|
| 42 |
+
/>
|
| 43 |
+
|
| 44 |
+
<div className="flex-1 ml-20 md:ml-72">
|
| 45 |
+
<main className="max-w-[1600px] mx-auto px-4 py-6 sm:px-6 md:px-10 md:py-10 lg:px-12 lg:py-12">
|
| 46 |
+
<header className="mb-12 flex flex-wrap items-center gap-4 md:gap-5">
|
| 47 |
+
<Library size={60} className="text-blue-500" />
|
| 48 |
+
|
| 49 |
+
<h1 className="text-3xl md:text-3xl font-black text-[var(--text-h)] m-0">
|
| 50 |
+
Codebookly Admin
|
| 51 |
+
</h1>
|
| 52 |
+
</header>
|
| 53 |
+
|
| 54 |
+
{mainView === "definitions" ? (
|
| 55 |
+
<DefinitionsExplorer />
|
| 56 |
+
) : (
|
| 57 |
+
<>
|
| 58 |
+
{sectionChapterMeta ? (
|
| 59 |
+
<SectionChapterBanner meta={sectionChapterMeta} />
|
| 60 |
+
) : null}
|
| 61 |
+
{designationMeta ? (
|
| 62 |
+
<SectionChapterBanner
|
| 63 |
+
meta={designationMeta}
|
| 64 |
+
contextLabel="Committee designation"
|
| 65 |
+
/>
|
| 66 |
+
) : null}
|
| 67 |
+
{agencyMeta ? (
|
| 68 |
+
<SectionChapterBanner meta={agencyMeta} contextLabel="Agency" />
|
| 69 |
+
) : null}
|
| 70 |
+
<CodeListSection codes={codes} loading={codesAreaLoading} />
|
| 71 |
+
</>
|
| 72 |
+
)}
|
| 73 |
+
</main>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export default App;
|
codebookly/src/assets/codebookly-icon.webp
ADDED
|
|
codebookly/src/assets/codebookly.jpg
ADDED
|
codebookly/src/assets/hero.png
ADDED
|
codebookly/src/assets/react.svg
ADDED
|
|
codebookly/src/assets/vite.svg
ADDED
|
|
codebookly/src/branding.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import codebooklyIconUrl from "./assets/codebookly-icon.webp";
|
| 2 |
+
import codebookIconUrl from "./assets/codebookly.jpg";
|
| 3 |
+
|
| 4 |
+
/** Resolved by Vite — use for `<img src>` and favicon (see `main.tsx`). */
|
| 5 |
+
export const CODEBOOKLY_ICON_SRC = codebooklyIconUrl;
|
| 6 |
+
export const CODEBOOK_ICON_SRC = codebookIconUrl;
|
codebookly/src/features/codes/components/CodeCard.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from "react";
|
| 2 |
+
import type { CodeRecord } from "../../../types/codebook";
|
| 3 |
+
|
| 4 |
+
type Props = {
|
| 5 |
+
code: CodeRecord;
|
| 6 |
+
index: number;
|
| 7 |
+
selected: boolean;
|
| 8 |
+
onSelectionClick: (
|
| 9 |
+
e: React.MouseEvent<HTMLInputElement>,
|
| 10 |
+
index: number,
|
| 11 |
+
id: string,
|
| 12 |
+
) => void;
|
| 13 |
+
onOpenDetail: () => void;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function CodeCardImpl({
|
| 17 |
+
code,
|
| 18 |
+
index,
|
| 19 |
+
selected,
|
| 20 |
+
onSelectionClick,
|
| 21 |
+
onOpenDetail,
|
| 22 |
+
}: Props) {
|
| 23 |
+
return (
|
| 24 |
+
<article
|
| 25 |
+
className={`rounded-md border bg-card ${
|
| 26 |
+
selected
|
| 27 |
+
? "ring-2 ring-primary border-primary shadow-sm"
|
| 28 |
+
: "border-border-ui hover:border-primary"
|
| 29 |
+
}`}
|
| 30 |
+
>
|
| 31 |
+
<div className="flex gap-2 p-3">
|
| 32 |
+
<label
|
| 33 |
+
className="shrink-0 cursor-pointer pt-0.5 select-none"
|
| 34 |
+
onClick={(e) => e.stopPropagation()}
|
| 35 |
+
data-code-checkbox
|
| 36 |
+
>
|
| 37 |
+
<input
|
| 38 |
+
type="checkbox"
|
| 39 |
+
readOnly
|
| 40 |
+
className="size-3.5 rounded border-border-ui accent-[var(--primary)] cursor-pointer"
|
| 41 |
+
checked={selected}
|
| 42 |
+
onClick={(e) => onSelectionClick(e, index, code.code)}
|
| 43 |
+
aria-label={`Select code ${code.code}`}
|
| 44 |
+
/>
|
| 45 |
+
</label>
|
| 46 |
+
|
| 47 |
+
<button
|
| 48 |
+
type="button"
|
| 49 |
+
onClick={onOpenDetail}
|
| 50 |
+
className="flex-1 min-w-0 text-left rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg)] cursor-pointer border-0 bg-transparent p-0 font-inherit"
|
| 51 |
+
>
|
| 52 |
+
<div className="mb-2">
|
| 53 |
+
<span className="block text-[0.80rem] uppercase text-primary tracking-wide mb-0.5 font-medium line-clamp-1">
|
| 54 |
+
{code.section_code}
|
| 55 |
+
{code.section_title != null && code.section_title !== ""
|
| 56 |
+
? ` — ${code.section_title}`
|
| 57 |
+
: ""}
|
| 58 |
+
</span>
|
| 59 |
+
<h2 className="text-sm font-extrabold m-0 text-text-main leading-tight">
|
| 60 |
+
{code.code}
|
| 61 |
+
</h2>
|
| 62 |
+
<h3 className="text-xs font-semibold text-text-muted mt-0.5 mb-0 line-clamp-2 leading-snug">
|
| 63 |
+
{code.title}
|
| 64 |
+
</h3>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div className="mb-2">
|
| 68 |
+
<p className="leading-snug text-[0.8rem] text-text-main line-clamp-3 m-0">
|
| 69 |
+
{code.content}
|
| 70 |
+
</p>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="flex flex-wrap gap-1.5 items-center pt-2 border-t border-border-ui">
|
| 74 |
+
<span className="bg-border-ui px-1.5 py-0.5 rounded text-[0.65rem] font-bold uppercase text-text-main">
|
| 75 |
+
Ch. {code.chapter}
|
| 76 |
+
</span>
|
| 77 |
+
{code.table && (
|
| 78 |
+
<span className="text-[0.7rem] text-text-muted truncate max-w-full">
|
| 79 |
+
Tbl {code.table}
|
| 80 |
+
</span>
|
| 81 |
+
)}
|
| 82 |
+
</div>
|
| 83 |
+
</button>
|
| 84 |
+
</div>
|
| 85 |
+
</article>
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export default memo(CodeCardImpl);
|
codebookly/src/features/codes/components/CodeFullContextModal.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEnrichedCode } from "../hooks/useEnrichedCode";
|
| 2 |
+
import { EnrichedCodeContent } from "./EnrichedCodeContent";
|
| 3 |
+
|
| 4 |
+
type Props = {
|
| 5 |
+
open: boolean;
|
| 6 |
+
codeId: string | null;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export function CodeFullContextModal({ open, codeId, onClose }: Props) {
|
| 11 |
+
const state = useEnrichedCode(open ? codeId : null);
|
| 12 |
+
|
| 13 |
+
if (!open) return null;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div
|
| 17 |
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 md:p-8"
|
| 18 |
+
role="presentation"
|
| 19 |
+
>
|
| 20 |
+
<button
|
| 21 |
+
type="button"
|
| 22 |
+
aria-label="Close dialog"
|
| 23 |
+
className="absolute inset-0 bg-black/60 backdrop-blur-[2px] cursor-default border-0 p-0"
|
| 24 |
+
onClick={onClose}
|
| 25 |
+
/>
|
| 26 |
+
<div
|
| 27 |
+
role="dialog"
|
| 28 |
+
aria-modal="true"
|
| 29 |
+
aria-labelledby="full-context-title"
|
| 30 |
+
className="relative z-10 w-full max-w-3xl max-h-[min(90vh,900px)] overflow-y-auto rounded-xl border border-border-ui bg-card p-6 md:p-8 shadow-xl text-left"
|
| 31 |
+
>
|
| 32 |
+
<div className="flex justify-between items-start gap-4 mb-6 sticky top-0 bg-card pb-2 border-b border-border-ui -mx-6 px-6 md:-mx-8 md:px-8 z-10">
|
| 33 |
+
<h2
|
| 34 |
+
id="full-context-title"
|
| 35 |
+
className="text-lg font-bold text-text-main m-0"
|
| 36 |
+
>
|
| 37 |
+
Full code context
|
| 38 |
+
{codeId != null ? (
|
| 39 |
+
<span className="text-text-muted font-normal"> · {codeId}</span>
|
| 40 |
+
) : null}
|
| 41 |
+
</h2>
|
| 42 |
+
<button
|
| 43 |
+
type="button"
|
| 44 |
+
onClick={onClose}
|
| 45 |
+
className="shrink-0 rounded-lg px-3 py-1.5 text-sm font-semibold bg-border-ui text-text-main hover:opacity-90 border-0 cursor-pointer"
|
| 46 |
+
>
|
| 47 |
+
Close
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{state.status === "idle" || state.status === "loading" ? (
|
| 52 |
+
<p className="text-text-muted m-0">Loading…</p>
|
| 53 |
+
) : null}
|
| 54 |
+
|
| 55 |
+
{state.status === "error" ? (
|
| 56 |
+
<p className="text-red-400 m-0" role="alert">
|
| 57 |
+
{state.message}
|
| 58 |
+
</p>
|
| 59 |
+
) : null}
|
| 60 |
+
|
| 61 |
+
{state.status === "ok" ? (
|
| 62 |
+
<div className="space-y-4">
|
| 63 |
+
{state.partial ? (
|
| 64 |
+
<p className="text-sm text-text-muted m-0 rounded-lg border border-border-ui bg-[var(--bg-card)] px-3 py-2">
|
| 65 |
+
Loaded code text only; standards, committee designations, and index
|
| 66 |
+
terms were not available.
|
| 67 |
+
</p>
|
| 68 |
+
) : null}
|
| 69 |
+
<EnrichedCodeContent enriched={state.data} />
|
| 70 |
+
</div>
|
| 71 |
+
) : null}
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
codebookly/src/features/codes/components/CodeListSection.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useState } from "react";
|
| 2 |
+
import { Loader2 } from "lucide-react";
|
| 3 |
+
import type { CodeRecord } from "../../../types/codebook";
|
| 4 |
+
import CodeCard from "./CodeCard";
|
| 5 |
+
import { CodeFullContextModal } from "./CodeFullContextModal";
|
| 6 |
+
import { SelectionActionBar } from "./SelectionActionBar";
|
| 7 |
+
import { useCodeListSelection } from "../hooks/useCodeListSelection";
|
| 8 |
+
import { downloadCodesJson } from "../utils/exportCodesJson";
|
| 9 |
+
import { downloadEnrichedJson } from "../utils/exportEnrichedJson";
|
| 10 |
+
import { fetchEnrichedForExport } from "../utils/fetchEnrichedForExport";
|
| 11 |
+
|
| 12 |
+
type Props = {
|
| 13 |
+
codes: CodeRecord[];
|
| 14 |
+
/** True while initial shell fetch or a browse navigation (section/chapter/etc.) is in flight */
|
| 15 |
+
loading?: boolean;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export function CodeListSection({ codes, loading = false }: Props) {
|
| 19 |
+
const [detailCode, setDetailCode] = useState<string | null>(null);
|
| 20 |
+
const [enrichedDownloadBusy, setEnrichedDownloadBusy] = useState(false);
|
| 21 |
+
|
| 22 |
+
const {
|
| 23 |
+
selected,
|
| 24 |
+
selectedCount,
|
| 25 |
+
clear,
|
| 26 |
+
selectAllVisible,
|
| 27 |
+
handleCheckboxClick,
|
| 28 |
+
selectedRecords,
|
| 29 |
+
} = useCodeListSelection(codes);
|
| 30 |
+
|
| 31 |
+
const handleOpenDetail = useCallback((code: string) => {
|
| 32 |
+
setDetailCode(code);
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const handleCloseModal = useCallback(() => {
|
| 36 |
+
setDetailCode(null);
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
const handleDownloadJson = useCallback(() => {
|
| 40 |
+
if (selectedRecords.length === 0) return;
|
| 41 |
+
const stamp = new Date().toISOString().slice(0, 10);
|
| 42 |
+
downloadCodesJson(selectedRecords, `codebookly-export-${stamp}.json`);
|
| 43 |
+
}, [selectedRecords]);
|
| 44 |
+
|
| 45 |
+
const handleDownloadEnrichedJson = useCallback(async () => {
|
| 46 |
+
if (selectedRecords.length === 0) return;
|
| 47 |
+
setEnrichedDownloadBusy(true);
|
| 48 |
+
try {
|
| 49 |
+
const ids = selectedRecords.map((c) => c.code);
|
| 50 |
+
const { enriched, errors } = await fetchEnrichedForExport(ids);
|
| 51 |
+
const stamp = new Date().toISOString().slice(0, 10);
|
| 52 |
+
downloadEnrichedJson(
|
| 53 |
+
enriched,
|
| 54 |
+
`codebookly-enriched-${stamp}.json`,
|
| 55 |
+
errors.length > 0 ? errors : undefined,
|
| 56 |
+
);
|
| 57 |
+
} finally {
|
| 58 |
+
setEnrichedDownloadBusy(false);
|
| 59 |
+
}
|
| 60 |
+
}, [selectedRecords]);
|
| 61 |
+
|
| 62 |
+
if (loading) {
|
| 63 |
+
return (
|
| 64 |
+
<div
|
| 65 |
+
className="flex min-h-[12rem] flex-col items-center justify-center gap-3 rounded-xl border border-border-ui bg-card px-6 py-16 text-text-muted"
|
| 66 |
+
role="status"
|
| 67 |
+
aria-live="polite"
|
| 68 |
+
aria-busy="true"
|
| 69 |
+
>
|
| 70 |
+
<Loader2 className="animate-spin text-primary" size={36} aria-hidden />
|
| 71 |
+
<p className="m-0 text-sm font-medium text-text-main">Loading codes…</p>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (codes.length === 0) return null;
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<>
|
| 80 |
+
<div className={selectedCount > 0 ? "pb-28 md:pb-24" : undefined}>
|
| 81 |
+
<p className="text-sm text-text-muted m-0 mb-4 max-w-3xl">
|
| 82 |
+
Use checkboxes to select codes.{" "}
|
| 83 |
+
<span className="text-text-main">Ctrl/Cmd+click</span> toggles one
|
| 84 |
+
without clearing others.{" "}
|
| 85 |
+
<span className="text-text-main">Shift+click</span> selects a range
|
| 86 |
+
from the last checkbox you used.{" "}
|
| 87 |
+
<span className="text-text-main">Codes JSON</span> uses the list only;{" "}
|
| 88 |
+
<span className="text-text-main">Enriched JSON</span> loads chapter
|
| 89 |
+
info, standards, designations, and index terms per code (one request
|
| 90 |
+
each).
|
| 91 |
+
</p>
|
| 92 |
+
|
| 93 |
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
| 94 |
+
{codes.map((code, index) => (
|
| 95 |
+
<CodeCard
|
| 96 |
+
key={code.code}
|
| 97 |
+
code={code}
|
| 98 |
+
index={index}
|
| 99 |
+
selected={selected.has(code.code)}
|
| 100 |
+
onSelectionClick={handleCheckboxClick}
|
| 101 |
+
onOpenDetail={() => handleOpenDetail(code.code)}
|
| 102 |
+
/>
|
| 103 |
+
))}
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<CodeFullContextModal
|
| 108 |
+
open={detailCode !== null}
|
| 109 |
+
codeId={detailCode}
|
| 110 |
+
onClose={handleCloseModal}
|
| 111 |
+
/>
|
| 112 |
+
|
| 113 |
+
<SelectionActionBar
|
| 114 |
+
count={selectedCount}
|
| 115 |
+
visibleCount={codes.length}
|
| 116 |
+
onClear={clear}
|
| 117 |
+
onSelectAllVisible={selectAllVisible}
|
| 118 |
+
onDownloadJson={handleDownloadJson}
|
| 119 |
+
onDownloadEnrichedJson={handleDownloadEnrichedJson}
|
| 120 |
+
enrichedDownloadBusy={enrichedDownloadBusy}
|
| 121 |
+
/>
|
| 122 |
+
</>
|
| 123 |
+
);
|
| 124 |
+
}
|
codebookly/src/features/codes/components/EnrichedCodeContent.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from "react";
|
| 2 |
+
import type { EnrichedCode } from "../../../types/codebook";
|
| 3 |
+
|
| 4 |
+
function EnrichedCodeContentImpl({ enriched }: { enriched: EnrichedCode }) {
|
| 5 |
+
const { code, standards, committee_designations, index_terms } = enriched;
|
| 6 |
+
|
| 7 |
+
return (
|
| 8 |
+
<div className="space-y-8 text-left">
|
| 9 |
+
<section>
|
| 10 |
+
<span className="block text-[0.75rem] uppercase text-primary tracking-wider mb-1 font-medium">
|
| 11 |
+
{code.section_code}
|
| 12 |
+
{code.section_title != null && code.section_title !== ""
|
| 13 |
+
? ` — ${code.section_title}`
|
| 14 |
+
: ""}
|
| 15 |
+
</span>
|
| 16 |
+
<h2 className="text-xl font-extrabold m-0 text-text-main">{code.code}</h2>
|
| 17 |
+
<h3 className="text-base font-semibold text-text-muted mt-1 mb-4">
|
| 18 |
+
{code.title}
|
| 19 |
+
</h3>
|
| 20 |
+
<p className="leading-relaxed text-[0.95rem] text-text-main whitespace-pre-wrap">
|
| 21 |
+
{code.content}
|
| 22 |
+
</p>
|
| 23 |
+
<div className="flex flex-wrap gap-3 items-center pt-4 mt-4 border-t border-border-ui">
|
| 24 |
+
<span className="bg-border-ui px-2 py-0.5 rounded text-[0.7rem] font-bold uppercase text-text-main">
|
| 25 |
+
Chapter {code.chapter}
|
| 26 |
+
</span>
|
| 27 |
+
{code.table ? (
|
| 28 |
+
<span className="text-[0.8rem] text-text-muted">Table {code.table}</span>
|
| 29 |
+
) : null}
|
| 30 |
+
{code.figure ? (
|
| 31 |
+
<span className="text-[0.8rem] text-text-muted">Figure {code.figure}</span>
|
| 32 |
+
) : null}
|
| 33 |
+
</div>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
{standards.length > 0 ? (
|
| 37 |
+
<section className="border-t border-border-ui pt-6">
|
| 38 |
+
<h4 className="text-sm font-bold uppercase tracking-wide text-primary mb-3">
|
| 39 |
+
Standards
|
| 40 |
+
</h4>
|
| 41 |
+
<ul className="list-none m-0 p-0 space-y-4">
|
| 42 |
+
{standards.map((s, i) => (
|
| 43 |
+
<li
|
| 44 |
+
key={`${s.agency}-${s.standard_id}-${i}`}
|
| 45 |
+
className="rounded-md border border-border-ui bg-[var(--bg-card)] p-3"
|
| 46 |
+
>
|
| 47 |
+
<div className="text-[0.8rem] font-bold text-text-main">
|
| 48 |
+
{s.agency} · {s.standard_id}
|
| 49 |
+
</div>
|
| 50 |
+
{s.definition ? (
|
| 51 |
+
<p className="text-[0.85rem] text-text-muted m-0 mt-2 leading-relaxed">
|
| 52 |
+
{s.definition}
|
| 53 |
+
</p>
|
| 54 |
+
) : null}
|
| 55 |
+
</li>
|
| 56 |
+
))}
|
| 57 |
+
</ul>
|
| 58 |
+
</section>
|
| 59 |
+
) : null}
|
| 60 |
+
|
| 61 |
+
{committee_designations.length > 0 ? (
|
| 62 |
+
<section className="border-t border-border-ui pt-6">
|
| 63 |
+
<h4 className="text-sm font-bold uppercase tracking-wide text-primary mb-3">
|
| 64 |
+
Committee designations
|
| 65 |
+
</h4>
|
| 66 |
+
<ul className="list-none m-0 p-0 space-y-2">
|
| 67 |
+
{committee_designations.map((c) => (
|
| 68 |
+
<li key={c.letter_tag} className="text-[0.9rem]">
|
| 69 |
+
<span className="font-bold text-text-main">{c.letter_tag}</span>
|
| 70 |
+
{c.description ? (
|
| 71 |
+
<span className="text-text-muted"> — {c.description}</span>
|
| 72 |
+
) : null}
|
| 73 |
+
</li>
|
| 74 |
+
))}
|
| 75 |
+
</ul>
|
| 76 |
+
</section>
|
| 77 |
+
) : null}
|
| 78 |
+
|
| 79 |
+
{index_terms.length > 0 ? (
|
| 80 |
+
<section className="border-t border-border-ui pt-6">
|
| 81 |
+
<h4 className="text-sm font-bold uppercase tracking-wide text-primary mb-3">
|
| 82 |
+
Index terms
|
| 83 |
+
</h4>
|
| 84 |
+
<ul className="list-none m-0 p-0 space-y-3">
|
| 85 |
+
{index_terms.map((t) => (
|
| 86 |
+
<li
|
| 87 |
+
key={t.id}
|
| 88 |
+
className="text-[0.85rem] border-l-2 border-primary/40 pl-3"
|
| 89 |
+
>
|
| 90 |
+
<div className="font-semibold text-text-main">{t.term}</div>
|
| 91 |
+
{t.label ? (
|
| 92 |
+
<div className="text-text-muted mt-0.5">{t.label}</div>
|
| 93 |
+
) : null}
|
| 94 |
+
<div className="text-text-muted text-[0.8rem] mt-1">
|
| 95 |
+
{t.ref_type} {t.ref_id}
|
| 96 |
+
{t.breadcrumb ? ` · ${t.breadcrumb}` : ""}
|
| 97 |
+
</div>
|
| 98 |
+
</li>
|
| 99 |
+
))}
|
| 100 |
+
</ul>
|
| 101 |
+
</section>
|
| 102 |
+
) : null}
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export const EnrichedCodeContent = memo(EnrichedCodeContentImpl);
|
codebookly/src/features/codes/components/SelectionActionBar.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Download, Layers, Loader2 } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
count: number;
|
| 5 |
+
onClear: () => void;
|
| 6 |
+
onSelectAllVisible: () => void;
|
| 7 |
+
onDownloadJson: () => void;
|
| 8 |
+
onDownloadEnrichedJson: () => void;
|
| 9 |
+
enrichedDownloadBusy: boolean;
|
| 10 |
+
visibleCount: number;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export function SelectionActionBar({
|
| 14 |
+
count,
|
| 15 |
+
onClear,
|
| 16 |
+
onSelectAllVisible,
|
| 17 |
+
onDownloadJson,
|
| 18 |
+
onDownloadEnrichedJson,
|
| 19 |
+
enrichedDownloadBusy,
|
| 20 |
+
visibleCount,
|
| 21 |
+
}: Props) {
|
| 22 |
+
if (count === 0) return null;
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div
|
| 26 |
+
className="fixed bottom-0 right-0 z-40 border-t border-[var(--border)] bg-[var(--bg-card)]/95 backdrop-blur-sm px-4 py-3 shadow-lg left-20 md:left-72"
|
| 27 |
+
role="region"
|
| 28 |
+
aria-label="Selection actions"
|
| 29 |
+
>
|
| 30 |
+
<div className="max-w-[1600px] mx-auto flex flex-wrap items-center justify-between gap-3">
|
| 31 |
+
<p className="text-sm text-[var(--text-main)] m-0">
|
| 32 |
+
<span className="font-semibold tabular-nums">{count}</span> selected
|
| 33 |
+
</p>
|
| 34 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 35 |
+
<button
|
| 36 |
+
type="button"
|
| 37 |
+
onClick={onSelectAllVisible}
|
| 38 |
+
disabled={visibleCount === 0}
|
| 39 |
+
className="text-sm font-medium rounded-lg px-3 py-2 border border-[var(--border)] text-[var(--text-main)] hover:bg-[#334155] disabled:opacity-40 disabled:pointer-events-none"
|
| 40 |
+
>
|
| 41 |
+
Select all visible
|
| 42 |
+
</button>
|
| 43 |
+
<button
|
| 44 |
+
type="button"
|
| 45 |
+
onClick={onClear}
|
| 46 |
+
className="text-sm font-medium rounded-lg px-3 py-2 border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text-main)] hover:bg-[#334155]"
|
| 47 |
+
>
|
| 48 |
+
Clear
|
| 49 |
+
</button>
|
| 50 |
+
<button
|
| 51 |
+
type="button"
|
| 52 |
+
onClick={onDownloadJson}
|
| 53 |
+
className="inline-flex items-center gap-2 text-sm font-semibold rounded-lg px-4 py-2 bg-[var(--primary)] text-white hover:opacity-90"
|
| 54 |
+
>
|
| 55 |
+
<Download size={16} aria-hidden />
|
| 56 |
+
Codes JSON
|
| 57 |
+
</button>
|
| 58 |
+
<button
|
| 59 |
+
type="button"
|
| 60 |
+
onClick={onDownloadEnrichedJson}
|
| 61 |
+
disabled={enrichedDownloadBusy}
|
| 62 |
+
className="inline-flex items-center gap-2 text-sm font-semibold rounded-lg px-4 py-2 border border-[var(--primary)] text-[var(--primary)] hover:bg-[#334155]/50 disabled:opacity-50 disabled:pointer-events-none"
|
| 63 |
+
>
|
| 64 |
+
{enrichedDownloadBusy ? (
|
| 65 |
+
<Loader2 size={16} className="animate-spin" aria-hidden />
|
| 66 |
+
) : (
|
| 67 |
+
<Layers size={16} aria-hidden />
|
| 68 |
+
)}
|
| 69 |
+
{enrichedDownloadBusy ? "Fetching…" : "Enriched JSON"}
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
codebookly/src/features/codes/hooks/useCodeListSelection.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import type { CodeRecord } from "../../../types/codebook";
|
| 3 |
+
|
| 4 |
+
export function useCodeListSelection(items: CodeRecord[]) {
|
| 5 |
+
const orderedIds = useMemo(() => items.map((c) => c.code), [items]);
|
| 6 |
+
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
| 7 |
+
const lastIndexRef = useRef<number | null>(null);
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
setSelected(new Set());
|
| 11 |
+
lastIndexRef.current = null;
|
| 12 |
+
}, [items]);
|
| 13 |
+
|
| 14 |
+
const clear = useCallback(() => {
|
| 15 |
+
setSelected(new Set());
|
| 16 |
+
lastIndexRef.current = null;
|
| 17 |
+
}, []);
|
| 18 |
+
|
| 19 |
+
const selectAllVisible = useCallback(() => {
|
| 20 |
+
setSelected(new Set(orderedIds));
|
| 21 |
+
lastIndexRef.current = orderedIds.length > 0 ? orderedIds.length - 1 : null;
|
| 22 |
+
}, [orderedIds]);
|
| 23 |
+
|
| 24 |
+
const handleCheckboxClick = useCallback(
|
| 25 |
+
(e: React.MouseEvent<HTMLInputElement>, index: number, id: string) => {
|
| 26 |
+
e.preventDefault();
|
| 27 |
+
e.stopPropagation();
|
| 28 |
+
|
| 29 |
+
if (e.shiftKey && lastIndexRef.current !== null) {
|
| 30 |
+
const a = Math.min(lastIndexRef.current, index);
|
| 31 |
+
const b = Math.max(lastIndexRef.current, index);
|
| 32 |
+
const range = orderedIds.slice(a, b + 1);
|
| 33 |
+
setSelected((prev) => {
|
| 34 |
+
const next = new Set(prev);
|
| 35 |
+
for (const rid of range) next.add(rid);
|
| 36 |
+
return next;
|
| 37 |
+
});
|
| 38 |
+
lastIndexRef.current = index;
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (e.metaKey || e.ctrlKey) {
|
| 43 |
+
setSelected((prev) => {
|
| 44 |
+
const next = new Set(prev);
|
| 45 |
+
if (next.has(id)) next.delete(id);
|
| 46 |
+
else next.add(id);
|
| 47 |
+
return next;
|
| 48 |
+
});
|
| 49 |
+
lastIndexRef.current = index;
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
setSelected((prev) => {
|
| 54 |
+
const next = new Set(prev);
|
| 55 |
+
if (next.has(id)) next.delete(id);
|
| 56 |
+
else next.add(id);
|
| 57 |
+
return next;
|
| 58 |
+
});
|
| 59 |
+
lastIndexRef.current = index;
|
| 60 |
+
},
|
| 61 |
+
[orderedIds],
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
const selectedRecords = useMemo(
|
| 65 |
+
() => items.filter((row) => selected.has(row.code)),
|
| 66 |
+
[items, selected],
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
selected,
|
| 71 |
+
selectedCount: selected.size,
|
| 72 |
+
clear,
|
| 73 |
+
selectAllVisible,
|
| 74 |
+
handleCheckboxClick,
|
| 75 |
+
selectedRecords,
|
| 76 |
+
};
|
| 77 |
+
}
|
codebookly/src/features/codes/hooks/useEnrichedCode.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { codeService } from "../../../services/codeApi";
|
| 3 |
+
import type { EnrichedCode } from "../../../types/codebook";
|
| 4 |
+
import { singleCodeResponseToEnriched } from "../utils/singleCodeToEnriched";
|
| 5 |
+
|
| 6 |
+
export type EnrichedCodeState =
|
| 7 |
+
| { status: "idle" }
|
| 8 |
+
| { status: "loading" }
|
| 9 |
+
| { status: "ok"; data: EnrichedCode; partial?: boolean }
|
| 10 |
+
| { status: "error"; message: string };
|
| 11 |
+
|
| 12 |
+
export function useEnrichedCode(codeId: string | null): EnrichedCodeState {
|
| 13 |
+
const [state, setState] = useState<EnrichedCodeState>({ status: "idle" });
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (codeId == null || codeId === "") {
|
| 17 |
+
setState({ status: "idle" });
|
| 18 |
+
return;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
let cancelled = false;
|
| 22 |
+
setState({ status: "loading" });
|
| 23 |
+
|
| 24 |
+
codeService
|
| 25 |
+
.getFullCodeContext(codeId)
|
| 26 |
+
.then((data) => {
|
| 27 |
+
if (!cancelled) setState({ status: "ok", data });
|
| 28 |
+
})
|
| 29 |
+
.catch(() => {
|
| 30 |
+
if (cancelled) return;
|
| 31 |
+
codeService
|
| 32 |
+
.getCode(codeId)
|
| 33 |
+
.then((single) => {
|
| 34 |
+
if (!cancelled) {
|
| 35 |
+
setState({
|
| 36 |
+
status: "ok",
|
| 37 |
+
data: singleCodeResponseToEnriched(single),
|
| 38 |
+
partial: true,
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
})
|
| 42 |
+
.catch(() => {
|
| 43 |
+
if (!cancelled)
|
| 44 |
+
setState({
|
| 45 |
+
status: "error",
|
| 46 |
+
message: "Could not load full context for this code.",
|
| 47 |
+
});
|
| 48 |
+
});
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
return () => {
|
| 52 |
+
cancelled = true;
|
| 53 |
+
};
|
| 54 |
+
}, [codeId]);
|
| 55 |
+
|
| 56 |
+
return state;
|
| 57 |
+
}
|
codebookly/src/features/codes/utils/exportCodesJson.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CodeRecord } from "../../../types/codebook";
|
| 2 |
+
|
| 3 |
+
export function downloadCodesJson(rows: CodeRecord[], filename: string) {
|
| 4 |
+
const payload = {
|
| 5 |
+
exportedAt: new Date().toISOString(),
|
| 6 |
+
count: rows.length,
|
| 7 |
+
codes: rows,
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
| 11 |
+
type: "application/json;charset=utf-8",
|
| 12 |
+
});
|
| 13 |
+
const url = URL.createObjectURL(blob);
|
| 14 |
+
const a = document.createElement("a");
|
| 15 |
+
a.href = url;
|
| 16 |
+
a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
|
| 17 |
+
a.click();
|
| 18 |
+
URL.revokeObjectURL(url);
|
| 19 |
+
}
|
codebookly/src/features/codes/utils/exportEnrichedJson.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { EnrichedCode } from "../../../types/codebook";
|
| 2 |
+
|
| 3 |
+
export type EnrichedExportError = { code: string; message: string };
|
| 4 |
+
|
| 5 |
+
export function downloadEnrichedJson(
|
| 6 |
+
enriched: EnrichedCode[],
|
| 7 |
+
filename: string,
|
| 8 |
+
fetchErrors?: EnrichedExportError[]
|
| 9 |
+
) {
|
| 10 |
+
const payload = {
|
| 11 |
+
exportedAt: new Date().toISOString(),
|
| 12 |
+
count: enriched.length,
|
| 13 |
+
enriched,
|
| 14 |
+
...(fetchErrors?.length
|
| 15 |
+
? {
|
| 16 |
+
fetchErrors,
|
| 17 |
+
note: "Some codes failed to load; see fetchErrors.",
|
| 18 |
+
}
|
| 19 |
+
: {}),
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
| 23 |
+
type: "application/json;charset=utf-8",
|
| 24 |
+
});
|
| 25 |
+
const url = URL.createObjectURL(blob);
|
| 26 |
+
const a = document.createElement("a");
|
| 27 |
+
a.href = url;
|
| 28 |
+
a.download = filename.endsWith(".json") ? filename : `${filename}.json`;
|
| 29 |
+
a.click();
|
| 30 |
+
URL.revokeObjectURL(url);
|
| 31 |
+
}
|
codebookly/src/features/codes/utils/fetchEnrichedForExport.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { codeService } from "../../../services/codeApi";
|
| 2 |
+
import type { EnrichedCode } from "../../../types/codebook";
|
| 3 |
+
|
| 4 |
+
const DEFAULT_CONCURRENCY = 4;
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Fetches full-context payloads for each code id in parallel with a concurrency cap.
|
| 8 |
+
* Preserves successful results in the same order as `codeIds`.
|
| 9 |
+
*/
|
| 10 |
+
export async function fetchEnrichedForExport(
|
| 11 |
+
codeIds: string[],
|
| 12 |
+
concurrency = DEFAULT_CONCURRENCY
|
| 13 |
+
): Promise<{
|
| 14 |
+
enriched: EnrichedCode[];
|
| 15 |
+
errors: { code: string; message: string }[];
|
| 16 |
+
}> {
|
| 17 |
+
if (codeIds.length === 0) {
|
| 18 |
+
return { enriched: [], errors: [] };
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const slots: (EnrichedCode | undefined)[] = new Array(codeIds.length);
|
| 22 |
+
const errors: { code: string; message: string }[] = [];
|
| 23 |
+
let nextIndex = 0;
|
| 24 |
+
|
| 25 |
+
async function worker() {
|
| 26 |
+
while (true) {
|
| 27 |
+
const i = nextIndex++;
|
| 28 |
+
if (i >= codeIds.length) return;
|
| 29 |
+
const id = codeIds[i];
|
| 30 |
+
try {
|
| 31 |
+
slots[i] = await codeService.getFullCodeContext(id);
|
| 32 |
+
} catch (e) {
|
| 33 |
+
errors.push({
|
| 34 |
+
code: id,
|
| 35 |
+
message: e instanceof Error ? e.message : String(e),
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const pool = Math.min(Math.max(1, concurrency), codeIds.length);
|
| 42 |
+
await Promise.all(Array.from({ length: pool }, () => worker()));
|
| 43 |
+
|
| 44 |
+
const enriched = codeIds
|
| 45 |
+
.map((_, i) => slots[i])
|
| 46 |
+
.filter((x): x is EnrichedCode => x != null);
|
| 47 |
+
|
| 48 |
+
return { enriched, errors };
|
| 49 |
+
}
|
codebookly/src/features/codes/utils/singleCodeToEnriched.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { EnrichedCode, SingleCodeResponse } from "../../../types/codebook";
|
| 2 |
+
|
| 3 |
+
/** Builds an EnrichedCode-shaped payload for the modal when only GET /api/code is available. */
|
| 4 |
+
export function singleCodeResponseToEnriched(res: SingleCodeResponse): EnrichedCode {
|
| 5 |
+
const { code, chapter_metadata } = res;
|
| 6 |
+
return {
|
| 7 |
+
code,
|
| 8 |
+
chapter_info: {
|
| 9 |
+
title: chapter_metadata.title,
|
| 10 |
+
description: chapter_metadata.description,
|
| 11 |
+
about: "",
|
| 12 |
+
chapter: code.chapter ?? "",
|
| 13 |
+
},
|
| 14 |
+
standards: [],
|
| 15 |
+
committee_designations: [],
|
| 16 |
+
index_terms: [],
|
| 17 |
+
};
|
| 18 |
+
}
|
codebookly/src/features/definitions/components/DefinitionRow.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo } from "react";
|
| 2 |
+
import type { DefinitionEntry } from "../../../types/definitions";
|
| 3 |
+
|
| 4 |
+
export const DefinitionRow = memo(function DefinitionRow({
|
| 5 |
+
row,
|
| 6 |
+
}: {
|
| 7 |
+
row: DefinitionEntry;
|
| 8 |
+
}) {
|
| 9 |
+
return (
|
| 10 |
+
<tr className="border-b border-[var(--border)] hover:bg-[#252525]/80 align-top">
|
| 11 |
+
<td className="py-2 px-2 md:py-2.5 md:px-3 text-xs md:text-sm font-semibold text-[var(--text-main)] max-w-[8rem] md:max-w-[12rem]">
|
| 12 |
+
<span className="line-clamp-2" title={row.term}>
|
| 13 |
+
{row.term}
|
| 14 |
+
</span>
|
| 15 |
+
</td>
|
| 16 |
+
<td className="py-2 px-2 md:py-2.5 md:px-3 text-[0.65rem] md:text-xs text-primary uppercase whitespace-nowrap">
|
| 17 |
+
{row.letter_tag || "—"}
|
| 18 |
+
</td>
|
| 19 |
+
<td className="py-2 px-2 md:py-2.5 md:px-3 text-[0.65rem] md:text-xs text-[var(--text-muted)] max-w-[7rem] md:max-w-[10rem] hidden sm:table-cell">
|
| 20 |
+
<span className="line-clamp-2" title={row.committee_designation}>
|
| 21 |
+
{row.committee_designation}
|
| 22 |
+
</span>
|
| 23 |
+
</td>
|
| 24 |
+
<td className="py-2 px-2 md:py-2.5 md:px-3 text-xs md:text-sm text-[var(--text-main)]">
|
| 25 |
+
<span
|
| 26 |
+
className="line-clamp-3 md:line-clamp-4 leading-snug"
|
| 27 |
+
title={row.definition}
|
| 28 |
+
>
|
| 29 |
+
{row.definition}
|
| 30 |
+
</span>
|
| 31 |
+
</td>
|
| 32 |
+
</tr>
|
| 33 |
+
);
|
| 34 |
+
});
|
codebookly/src/features/definitions/components/DefinitionsExplorer.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BookOpen } from "lucide-react";
|
| 2 |
+
import { useDefinitionsBrowse } from "../hooks/useDefinitionsBrowse";
|
| 3 |
+
import { DefinitionsSearchForm } from "./DefinitionsSearchForm";
|
| 4 |
+
import { DefinitionsTableSection } from "./DefinitionsTableSection";
|
| 5 |
+
|
| 6 |
+
export function DefinitionsExplorer() {
|
| 7 |
+
const {
|
| 8 |
+
data,
|
| 9 |
+
loading,
|
| 10 |
+
error,
|
| 11 |
+
exportBusy,
|
| 12 |
+
runSearch,
|
| 13 |
+
goToPage,
|
| 14 |
+
exportJson,
|
| 15 |
+
rangeLabel,
|
| 16 |
+
hasResults,
|
| 17 |
+
} = useDefinitionsBrowse();
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<section className="space-y-4" aria-labelledby="definitions-heading">
|
| 21 |
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
| 22 |
+
<div>
|
| 23 |
+
<h2
|
| 24 |
+
id="definitions-heading"
|
| 25 |
+
className="text-2xl md:text-3xl font-black text-[var(--text-h)] m-0 flex items-center gap-2"
|
| 26 |
+
>
|
| 27 |
+
<BookOpen className="shrink-0 text-[var(--primary)]" size={28} aria-hidden />
|
| 28 |
+
Definitions
|
| 29 |
+
</h2>
|
| 30 |
+
<p className="text-sm text-[var(--text-muted)] mt-2 mb-0 max-w-2xl">
|
| 31 |
+
The first page loads automatically so you can browse. Use Search to run a
|
| 32 |
+
server query over terms, definitions, and committee text (
|
| 33 |
+
<code className="text-[var(--text-main)]">GET /api/definitions?q=…</code>
|
| 34 |
+
). Pagination keeps the same query. Download matches the results you are
|
| 35 |
+
viewing.
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<DefinitionsSearchForm
|
| 41 |
+
onSearch={runSearch}
|
| 42 |
+
loading={loading}
|
| 43 |
+
exportBusy={exportBusy}
|
| 44 |
+
hasResults={hasResults}
|
| 45 |
+
onExport={exportJson}
|
| 46 |
+
/>
|
| 47 |
+
|
| 48 |
+
<DefinitionsTableSection
|
| 49 |
+
data={data}
|
| 50 |
+
loading={loading}
|
| 51 |
+
error={error}
|
| 52 |
+
rangeLabel={rangeLabel}
|
| 53 |
+
goToPage={goToPage}
|
| 54 |
+
/>
|
| 55 |
+
</section>
|
| 56 |
+
);
|
| 57 |
+
}
|
codebookly/src/features/definitions/components/DefinitionsSearchForm.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useState } from "react";
|
| 2 |
+
import { Download, Loader2, Search } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
type Props = {
|
| 5 |
+
onSearch: (query: string) => void;
|
| 6 |
+
loading: boolean;
|
| 7 |
+
exportBusy: boolean;
|
| 8 |
+
hasResults: boolean;
|
| 9 |
+
onExport: () => void;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export const DefinitionsSearchForm = memo(function DefinitionsSearchForm({
|
| 13 |
+
onSearch,
|
| 14 |
+
loading,
|
| 15 |
+
exportBusy,
|
| 16 |
+
hasResults,
|
| 17 |
+
onExport,
|
| 18 |
+
}: Props) {
|
| 19 |
+
const [draft, setDraft] = useState("");
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<form
|
| 23 |
+
className="flex flex-col sm:flex-row sm:items-end gap-3 p-4 rounded-lg border border-[var(--border)] bg-[var(--bg-card)]"
|
| 24 |
+
onSubmit={(e) => {
|
| 25 |
+
e.preventDefault();
|
| 26 |
+
onSearch(draft);
|
| 27 |
+
}}
|
| 28 |
+
>
|
| 29 |
+
<div className="flex-1 min-w-0 relative">
|
| 30 |
+
<label htmlFor="def-search" className="sr-only">
|
| 31 |
+
Search definitions
|
| 32 |
+
</label>
|
| 33 |
+
<Search
|
| 34 |
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] pointer-events-none"
|
| 35 |
+
size={18}
|
| 36 |
+
aria-hidden
|
| 37 |
+
/>
|
| 38 |
+
<input
|
| 39 |
+
id="def-search"
|
| 40 |
+
type="search"
|
| 41 |
+
value={draft}
|
| 42 |
+
onChange={(e) => setDraft(e.target.value)}
|
| 43 |
+
placeholder="Search terms, definitions, committee text…"
|
| 44 |
+
className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] pl-10 pr-3 py-2.5 text-sm text-[var(--text-main)] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]"
|
| 45 |
+
autoComplete="off"
|
| 46 |
+
/>
|
| 47 |
+
</div>
|
| 48 |
+
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
| 49 |
+
<button
|
| 50 |
+
type="submit"
|
| 51 |
+
disabled={loading}
|
| 52 |
+
className="inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold border border-[var(--primary)] text-[var(--primary)] hover:bg-[#334155]/50 disabled:opacity-50 disabled:pointer-events-none"
|
| 53 |
+
>
|
| 54 |
+
{loading ? (
|
| 55 |
+
<Loader2 size={16} className="animate-spin" aria-hidden />
|
| 56 |
+
) : (
|
| 57 |
+
<Search size={16} aria-hidden />
|
| 58 |
+
)}
|
| 59 |
+
Search
|
| 60 |
+
</button>
|
| 61 |
+
<button
|
| 62 |
+
type="button"
|
| 63 |
+
onClick={() => void onExport()}
|
| 64 |
+
disabled={exportBusy || loading || !hasResults}
|
| 65 |
+
className="inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 disabled:pointer-events-none"
|
| 66 |
+
>
|
| 67 |
+
{exportBusy ? (
|
| 68 |
+
<Loader2 size={16} className="animate-spin" aria-hidden />
|
| 69 |
+
) : (
|
| 70 |
+
<Download size={16} aria-hidden />
|
| 71 |
+
)}
|
| 72 |
+
Download JSON
|
| 73 |
+
</button>
|
| 74 |
+
</div>
|
| 75 |
+
</form>
|
| 76 |
+
);
|
| 77 |
+
});
|
codebookly/src/features/definitions/components/DefinitionsTableSection.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { memo, useMemo } from "react";
|
| 2 |
+
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
| 3 |
+
import type { DefinitionPageResponse } from "../../../types/definitions";
|
| 4 |
+
import { DefinitionRow } from "./DefinitionRow";
|
| 5 |
+
|
| 6 |
+
type Props = {
|
| 7 |
+
data: DefinitionPageResponse | null;
|
| 8 |
+
loading: boolean;
|
| 9 |
+
error: string | null;
|
| 10 |
+
rangeLabel: string;
|
| 11 |
+
goToPage: (p: number) => void;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export const DefinitionsTableSection = memo(function DefinitionsTableSection({
|
| 15 |
+
data,
|
| 16 |
+
loading,
|
| 17 |
+
error,
|
| 18 |
+
rangeLabel,
|
| 19 |
+
goToPage,
|
| 20 |
+
}: Props) {
|
| 21 |
+
const tableRows = useMemo(
|
| 22 |
+
() =>
|
| 23 |
+
data?.items.map((row, i) => (
|
| 24 |
+
<DefinitionRow
|
| 25 |
+
key={`${data.page}-${i}-${row.term}-${row.letter_tag}`}
|
| 26 |
+
row={row}
|
| 27 |
+
/>
|
| 28 |
+
)),
|
| 29 |
+
[data]
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<>
|
| 34 |
+
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-[var(--text-muted)] min-h-[1.5rem]">
|
| 35 |
+
<span>{rangeLabel}</span>
|
| 36 |
+
{data && data.total_pages > 1 && (
|
| 37 |
+
<div className="flex items-center gap-1">
|
| 38 |
+
<button
|
| 39 |
+
type="button"
|
| 40 |
+
disabled={data.page <= 1 || loading}
|
| 41 |
+
onClick={() => goToPage(data.page - 1)}
|
| 42 |
+
className="p-2 rounded-lg border border-[var(--border)] text-[var(--text-main)] hover:bg-[#334155] disabled:opacity-40 disabled:pointer-events-none"
|
| 43 |
+
aria-label="Previous page"
|
| 44 |
+
>
|
| 45 |
+
<ChevronLeft size={18} />
|
| 46 |
+
</button>
|
| 47 |
+
<span className="tabular-nums px-2 text-[var(--text-main)]">
|
| 48 |
+
Page {data.page} / {data.total_pages}
|
| 49 |
+
</span>
|
| 50 |
+
<button
|
| 51 |
+
type="button"
|
| 52 |
+
disabled={data.page >= data.total_pages || loading}
|
| 53 |
+
onClick={() => goToPage(data.page + 1)}
|
| 54 |
+
className="p-2 rounded-lg border border-[var(--border)] text-[var(--text-main)] hover:bg-[#334155] disabled:opacity-40 disabled:pointer-events-none"
|
| 55 |
+
aria-label="Next page"
|
| 56 |
+
>
|
| 57 |
+
<ChevronRight size={18} />
|
| 58 |
+
</button>
|
| 59 |
+
</div>
|
| 60 |
+
)}
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{error && (
|
| 64 |
+
<p className="text-sm text-red-400 m-0" role="alert">
|
| 65 |
+
{error}
|
| 66 |
+
</p>
|
| 67 |
+
)}
|
| 68 |
+
|
| 69 |
+
<div className="relative rounded-lg border border-[var(--border)] overflow-hidden bg-[var(--bg-card)]">
|
| 70 |
+
{loading && (
|
| 71 |
+
<div
|
| 72 |
+
className="absolute inset-0 z-10 flex items-center justify-center bg-[var(--bg-card)]/70 backdrop-blur-[2px]"
|
| 73 |
+
aria-busy="true"
|
| 74 |
+
aria-label="Loading"
|
| 75 |
+
>
|
| 76 |
+
<Loader2 className="animate-spin text-[var(--primary)]" size={32} />
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
<div className="overflow-x-auto">
|
| 80 |
+
<table className="w-full min-w-[640px] border-collapse text-left">
|
| 81 |
+
<thead>
|
| 82 |
+
<tr className="bg-[#252525] text-[var(--text-muted)] text-[0.65rem] md:text-xs uppercase tracking-wide">
|
| 83 |
+
<th className="py-2.5 px-2 md:px-3 font-semibold">Term</th>
|
| 84 |
+
<th className="py-2.5 px-2 md:px-3 font-semibold">Tag</th>
|
| 85 |
+
<th className="py-2.5 px-2 md:px-3 font-semibold hidden sm:table-cell">
|
| 86 |
+
Committee
|
| 87 |
+
</th>
|
| 88 |
+
<th className="py-2.5 px-2 md:px-3 font-semibold">Definition</th>
|
| 89 |
+
</tr>
|
| 90 |
+
</thead>
|
| 91 |
+
<tbody>{tableRows}</tbody>
|
| 92 |
+
</table>
|
| 93 |
+
</div>
|
| 94 |
+
{!loading && data && data.items.length === 0 && (
|
| 95 |
+
<p className="text-sm text-[var(--text-muted)] p-8 text-center m-0">
|
| 96 |
+
No definitions match your search.
|
| 97 |
+
</p>
|
| 98 |
+
)}
|
| 99 |
+
</div>
|
| 100 |
+
</>
|
| 101 |
+
);
|
| 102 |
+
});
|
codebookly/src/features/definitions/hooks/useDefinitionsBrowse.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import axios from "axios";
|
| 3 |
+
import { definitionsBrowse } from "../services/definitionsBrowse";
|
| 4 |
+
import type { DefinitionPageResponse } from "../../../types/definitions";
|
| 5 |
+
|
| 6 |
+
const PAGE_SIZE = 24;
|
| 7 |
+
|
| 8 |
+
export function useDefinitionsBrowse() {
|
| 9 |
+
const [appliedQ, setAppliedQ] = useState("");
|
| 10 |
+
const [data, setData] = useState<DefinitionPageResponse | null>(null);
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
const [error, setError] = useState<string | null>(null);
|
| 13 |
+
const [exportBusy, setExportBusy] = useState(false);
|
| 14 |
+
|
| 15 |
+
const abortRef = useRef<AbortController | null>(null);
|
| 16 |
+
|
| 17 |
+
const loadPage = useCallback(async (targetPage: number, q: string) => {
|
| 18 |
+
abortRef.current?.abort();
|
| 19 |
+
const ac = new AbortController();
|
| 20 |
+
abortRef.current = ac;
|
| 21 |
+
|
| 22 |
+
setLoading(true);
|
| 23 |
+
setError(null);
|
| 24 |
+
try {
|
| 25 |
+
const res = await definitionsBrowse.fetchPage(
|
| 26 |
+
{
|
| 27 |
+
page: targetPage,
|
| 28 |
+
page_size: PAGE_SIZE,
|
| 29 |
+
q: q || undefined,
|
| 30 |
+
},
|
| 31 |
+
ac.signal
|
| 32 |
+
);
|
| 33 |
+
if (ac.signal.aborted) return;
|
| 34 |
+
setData(res);
|
| 35 |
+
setAppliedQ(q);
|
| 36 |
+
} catch (err) {
|
| 37 |
+
if (
|
| 38 |
+
ac.signal.aborted ||
|
| 39 |
+
axios.isCancel(err) ||
|
| 40 |
+
(axios.isAxiosError(err) && err.code === "ERR_CANCELED")
|
| 41 |
+
) {
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
setError(
|
| 45 |
+
err instanceof Error ? err.message : "Failed to load definitions"
|
| 46 |
+
);
|
| 47 |
+
setData(null);
|
| 48 |
+
} finally {
|
| 49 |
+
if (!ac.signal.aborted) setLoading(false);
|
| 50 |
+
}
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
void loadPage(1, "");
|
| 55 |
+
}, [loadPage]);
|
| 56 |
+
|
| 57 |
+
const runSearch = useCallback(
|
| 58 |
+
(q: string) => {
|
| 59 |
+
void loadPage(1, q.trim());
|
| 60 |
+
},
|
| 61 |
+
[loadPage]
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
const goToPage = useCallback(
|
| 65 |
+
(nextPage: number) => {
|
| 66 |
+
if (!data || nextPage < 1 || nextPage > data.total_pages) return;
|
| 67 |
+
void loadPage(nextPage, appliedQ);
|
| 68 |
+
},
|
| 69 |
+
[data, appliedQ, loadPage]
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
const exportJson = useCallback(async () => {
|
| 73 |
+
setExportBusy(true);
|
| 74 |
+
try {
|
| 75 |
+
await definitionsBrowse.downloadExport({
|
| 76 |
+
q: appliedQ || undefined,
|
| 77 |
+
});
|
| 78 |
+
} finally {
|
| 79 |
+
setExportBusy(false);
|
| 80 |
+
}
|
| 81 |
+
}, [appliedQ]);
|
| 82 |
+
|
| 83 |
+
const rangeLabel = useMemo(() => {
|
| 84 |
+
if (!data) return loading ? "Loading…" : "";
|
| 85 |
+
if (data.total === 0) return "No results";
|
| 86 |
+
const start = (data.page - 1) * data.page_size + 1;
|
| 87 |
+
const end = Math.min(data.page * data.page_size, data.total);
|
| 88 |
+
return `${start}–${end} of ${data.total}`;
|
| 89 |
+
}, [data, loading]);
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
data,
|
| 93 |
+
loading,
|
| 94 |
+
error,
|
| 95 |
+
exportBusy,
|
| 96 |
+
runSearch,
|
| 97 |
+
goToPage,
|
| 98 |
+
exportJson,
|
| 99 |
+
rangeLabel,
|
| 100 |
+
hasResults: data != null,
|
| 101 |
+
};
|
| 102 |
+
}
|
codebookly/src/features/definitions/services/definitionsBrowse.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { DefinitionPageResponse } from "../../../types/definitions";
|
| 2 |
+
import {
|
| 3 |
+
definitionsService,
|
| 4 |
+
type DefinitionsPageParams,
|
| 5 |
+
} from "../../../services/definitionsApi";
|
| 6 |
+
|
| 7 |
+
export const definitionsBrowse = {
|
| 8 |
+
fetchPage(
|
| 9 |
+
params: DefinitionsPageParams,
|
| 10 |
+
signal?: AbortSignal
|
| 11 |
+
): Promise<DefinitionPageResponse> {
|
| 12 |
+
return definitionsService.getPage(params, { signal });
|
| 13 |
+
},
|
| 14 |
+
|
| 15 |
+
downloadExport(params: { q?: string; letter_tag?: string }): Promise<void> {
|
| 16 |
+
return definitionsService.downloadExport(params);
|
| 17 |
+
},
|
| 18 |
+
};
|
codebookly/src/features/layout/SectionChapterBanner.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SectionChapterMetadata } from "../../types/codebook";
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
meta: SectionChapterMetadata;
|
| 5 |
+
contextLabel?: string;
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export function SectionChapterBanner({
|
| 9 |
+
meta,
|
| 10 |
+
contextLabel = "Chapter context",
|
| 11 |
+
}: Props) {
|
| 12 |
+
return (
|
| 13 |
+
<section
|
| 14 |
+
className="mb-8 rounded-xl border border-border-ui bg-card px-5 py-5 md:px-8 md:py-6"
|
| 15 |
+
aria-labelledby="section-chapter-banner-title"
|
| 16 |
+
>
|
| 17 |
+
<p className="text-[0.7rem] uppercase tracking-wider text-primary font-semibold m-0 mb-2">
|
| 18 |
+
{contextLabel}
|
| 19 |
+
</p>
|
| 20 |
+
<h2
|
| 21 |
+
id="section-chapter-banner-title"
|
| 22 |
+
className="text-xl md:text-2xl font-extrabold text-text-main m-0"
|
| 23 |
+
>
|
| 24 |
+
{meta.title}
|
| 25 |
+
</h2>
|
| 26 |
+
{meta.description ? (
|
| 27 |
+
<p className="text-sm md:text-base text-text-muted mt-3 mb-0 leading-relaxed">
|
| 28 |
+
{meta.description}
|
| 29 |
+
</p>
|
| 30 |
+
) : null}
|
| 31 |
+
</section>
|
| 32 |
+
);
|
| 33 |
+
}
|
codebookly/src/features/layout/SideBar.tsx
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
BookOpen,
|
| 4 |
+
Building2,
|
| 5 |
+
ChevronLeft,
|
| 6 |
+
Layers,
|
| 7 |
+
List,
|
| 8 |
+
Menu,
|
| 9 |
+
} from "lucide-react";
|
| 10 |
+
import { CODEBOOKLY_ICON_SRC } from "../../branding";
|
| 11 |
+
import type { Agency, CommitteeDesignation } from "../../types/codebook";
|
| 12 |
+
import { SidebarAccordion } from "./SidebarAccordion";
|
| 13 |
+
import { SIDEBAR_SUB_LINK, sidebarNavBtnClass } from "./sidebarTokens";
|
| 14 |
+
|
| 15 |
+
type Props = {
|
| 16 |
+
sections: { section: string; title: string }[];
|
| 17 |
+
chapters: { chapter: string; title: string }[];
|
| 18 |
+
designations: CommitteeDesignation[];
|
| 19 |
+
agencies: Agency[];
|
| 20 |
+
onSectionSelect: (sectionCode: string) => void;
|
| 21 |
+
onChapterSelect: (chapterKey: string) => void;
|
| 22 |
+
onDesignationSelect: (d: CommitteeDesignation) => void;
|
| 23 |
+
onAgencySelect: (a: Agency) => void;
|
| 24 |
+
onDefinitionsSelect: () => void;
|
| 25 |
+
definitionsActive?: boolean;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const SCROLL_PANEL = "max-h-[min(50vh,20rem)] overflow-y-auto custom-scrollbar";
|
| 29 |
+
|
| 30 |
+
export default function SideBar({
|
| 31 |
+
sections,
|
| 32 |
+
chapters,
|
| 33 |
+
designations: _designations,
|
| 34 |
+
agencies,
|
| 35 |
+
onSectionSelect,
|
| 36 |
+
onChapterSelect,
|
| 37 |
+
onDesignationSelect: _onDesignationSelect,
|
| 38 |
+
onAgencySelect,
|
| 39 |
+
onDefinitionsSelect,
|
| 40 |
+
definitionsActive = false,
|
| 41 |
+
}: Props) {
|
| 42 |
+
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
| 43 |
+
const [isExpanded, setIsExpanded] = useState(true);
|
| 44 |
+
|
| 45 |
+
const expandAndToggle = useCallback((menuName: string) => {
|
| 46 |
+
setIsExpanded(true);
|
| 47 |
+
setOpenMenu((prev) => (prev === menuName ? null : menuName));
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<aside
|
| 52 |
+
className={`fixed top-0 left-0 h-screen bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col z-50
|
| 53 |
+
${isExpanded ? "w-72" : "w-20"}`}
|
| 54 |
+
>
|
| 55 |
+
<div
|
| 56 |
+
className={`h-20 border-b border-[var(--border)] flex items-center gap-2 shrink-0 ${
|
| 57 |
+
isExpanded
|
| 58 |
+
? "px-6 justify-between flex-row"
|
| 59 |
+
: "px-2 flex-col justify-center"
|
| 60 |
+
}`}
|
| 61 |
+
>
|
| 62 |
+
<div
|
| 63 |
+
className={`flex items-center gap-3 min-w-0 ${
|
| 64 |
+
isExpanded ? "flex-1" : "justify-center"
|
| 65 |
+
}`}
|
| 66 |
+
>
|
| 67 |
+
{isExpanded ? (
|
| 68 |
+
<span className="text-xl font-bold tracking-tighter text-[var(--text-main)] truncate">
|
| 69 |
+
<img
|
| 70 |
+
src={CODEBOOKLY_ICON_SRC}
|
| 71 |
+
alt=""
|
| 72 |
+
width={500}
|
| 73 |
+
height={500}
|
| 74 |
+
className="size-24 shrink-0"
|
| 75 |
+
decoding="async"
|
| 76 |
+
/>
|
| 77 |
+
</span>
|
| 78 |
+
) : null}
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<button
|
| 82 |
+
type="button"
|
| 83 |
+
onClick={() => setIsExpanded((e) => !e)}
|
| 84 |
+
className="p-2 rounded-lg hover:bg-[#334155] text-[var(--text-muted)] hover:text-[var(--text-main)]"
|
| 85 |
+
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
| 86 |
+
>
|
| 87 |
+
{isExpanded ? <ChevronLeft size={20} /> : <Menu size={20} />}
|
| 88 |
+
</button>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<nav className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
| 92 |
+
<div className="space-y-1">
|
| 93 |
+
<button
|
| 94 |
+
type="button"
|
| 95 |
+
onClick={() => {
|
| 96 |
+
onDefinitionsSelect();
|
| 97 |
+
setOpenMenu(null);
|
| 98 |
+
}}
|
| 99 |
+
title={!isExpanded ? "Definitions" : undefined}
|
| 100 |
+
className={sidebarNavBtnClass(!!definitionsActive, isExpanded)}
|
| 101 |
+
>
|
| 102 |
+
<div className="flex items-center gap-3">
|
| 103 |
+
<BookOpen size={22} className="shrink-0" aria-hidden />
|
| 104 |
+
{isExpanded && (
|
| 105 |
+
<span className="font-semibold text-sm tracking-wide">
|
| 106 |
+
Definitions
|
| 107 |
+
</span>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
+
{isExpanded && <span className="w-4 shrink-0" aria-hidden />}
|
| 111 |
+
</button>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<SidebarAccordion
|
| 115 |
+
menuId="sections"
|
| 116 |
+
label="Sections"
|
| 117 |
+
icon={<Layers size={22} className="shrink-0" aria-hidden />}
|
| 118 |
+
isNavExpanded={isExpanded}
|
| 119 |
+
openMenu={openMenu}
|
| 120 |
+
onToggle={expandAndToggle}
|
| 121 |
+
>
|
| 122 |
+
{sections.map((section) => (
|
| 123 |
+
<button
|
| 124 |
+
type="button"
|
| 125 |
+
key={section.section}
|
| 126 |
+
className={`${SIDEBAR_SUB_LINK} w-full text-left border-0 bg-transparent cursor-pointer font-inherit`}
|
| 127 |
+
onClick={() => onSectionSelect(section.section)}
|
| 128 |
+
>
|
| 129 |
+
<div className="flex flex-row items-center gap-3 border-b border-[var(--border)] pb-2">
|
| 130 |
+
<span className=" text-[var(--text-main)]">
|
| 131 |
+
{section.section}
|
| 132 |
+
</span>
|
| 133 |
+
<span className="block text-[0.75rem] text-[var(--text-muted)]">
|
| 134 |
+
{section.title}
|
| 135 |
+
</span>
|
| 136 |
+
</div>
|
| 137 |
+
</button>
|
| 138 |
+
))}
|
| 139 |
+
</SidebarAccordion>
|
| 140 |
+
|
| 141 |
+
<SidebarAccordion
|
| 142 |
+
menuId="chapters"
|
| 143 |
+
label="Chapters"
|
| 144 |
+
icon={<List size={22} className="shrink-0" aria-hidden />}
|
| 145 |
+
isNavExpanded={isExpanded}
|
| 146 |
+
openMenu={openMenu}
|
| 147 |
+
onToggle={expandAndToggle}
|
| 148 |
+
>
|
| 149 |
+
{chapters.map((chapter) => (
|
| 150 |
+
<button
|
| 151 |
+
type="button"
|
| 152 |
+
key={chapter.chapter}
|
| 153 |
+
className={`${SIDEBAR_SUB_LINK} w-full text-left border-0 bg-transparent cursor-pointer font-inherit`}
|
| 154 |
+
onClick={() => onChapterSelect(chapter.chapter)}
|
| 155 |
+
>
|
| 156 |
+
<div className="flex flex-row items-center gap-3 border-b border-[var(--border)] pb-2">
|
| 157 |
+
<span className=" text-[var(--text-main)]">
|
| 158 |
+
{chapter.chapter}
|
| 159 |
+
</span>
|
| 160 |
+
<span className="block text-[0.75rem] text-[var(--text-muted)]">
|
| 161 |
+
{chapter.title}
|
| 162 |
+
</span>
|
| 163 |
+
</div>
|
| 164 |
+
</button>
|
| 165 |
+
))}
|
| 166 |
+
</SidebarAccordion>
|
| 167 |
+
|
| 168 |
+
{/* <SidebarAccordion
|
| 169 |
+
menuId="designations"
|
| 170 |
+
label="Designations"
|
| 171 |
+
icon={<Tags size={22} className="shrink-0" aria-hidden />}
|
| 172 |
+
isNavExpanded={isExpanded}
|
| 173 |
+
openMenu={openMenu}
|
| 174 |
+
onToggle={expandAndToggle}
|
| 175 |
+
collapsedTitle="Designations"
|
| 176 |
+
panelClassName={SCROLL_PANEL}
|
| 177 |
+
>
|
| 178 |
+
{designations.length === 0 ? (
|
| 179 |
+
<span
|
| 180 |
+
className={`${SIDEBAR_SUB_LINK} text-[var(--text-muted)] cursor-default`}
|
| 181 |
+
>
|
| 182 |
+
None loaded
|
| 183 |
+
</span>
|
| 184 |
+
) : (
|
| 185 |
+
designations.map((d) => (
|
| 186 |
+
<button
|
| 187 |
+
type="button"
|
| 188 |
+
key={d.letter_tag}
|
| 189 |
+
className={`${SIDEBAR_SUB_LINK} w-full text-left border-0 bg-transparent cursor-pointer font-inherit`}
|
| 190 |
+
title={d.description || undefined}
|
| 191 |
+
onClick={() => onDesignationSelect(d)}
|
| 192 |
+
>
|
| 193 |
+
<span className="block text-[0.75rem] text-[var(--text-muted)] mt-0.5 line-clamp-2">
|
| 194 |
+
{d.description}
|
| 195 |
+
</span>
|
| 196 |
+
{d.description ? (
|
| 197 |
+
<span className="font-semibold text-[var(--text-main)]">
|
| 198 |
+
{d.letter_tag}
|
| 199 |
+
</span>
|
| 200 |
+
) : null}
|
| 201 |
+
</button>
|
| 202 |
+
))
|
| 203 |
+
)}
|
| 204 |
+
</SidebarAccordion> */}
|
| 205 |
+
|
| 206 |
+
<SidebarAccordion
|
| 207 |
+
menuId="agencies"
|
| 208 |
+
label="Referenced Standards"
|
| 209 |
+
icon={<Building2 size={22} className="shrink-0" aria-hidden />}
|
| 210 |
+
isNavExpanded={isExpanded}
|
| 211 |
+
openMenu={openMenu}
|
| 212 |
+
onToggle={expandAndToggle}
|
| 213 |
+
collapsedTitle="Agencies"
|
| 214 |
+
panelClassName={SCROLL_PANEL}
|
| 215 |
+
>
|
| 216 |
+
{agencies.length === 0 ? (
|
| 217 |
+
<span
|
| 218 |
+
className={`${SIDEBAR_SUB_LINK} text-[var(--text-muted)] cursor-default`}
|
| 219 |
+
>
|
| 220 |
+
None loaded
|
| 221 |
+
</span>
|
| 222 |
+
) : (
|
| 223 |
+
agencies.map((a) => (
|
| 224 |
+
<button
|
| 225 |
+
type="button"
|
| 226 |
+
key={a.agency}
|
| 227 |
+
className={`${SIDEBAR_SUB_LINK} w-full text-left border-0 bg-transparent cursor-pointer font-inherit`}
|
| 228 |
+
title={a.agency_info || undefined}
|
| 229 |
+
onClick={() => onAgencySelect(a)}
|
| 230 |
+
>
|
| 231 |
+
<span className="font-semibold text-[var(--text-main)]">
|
| 232 |
+
{a.agency}
|
| 233 |
+
</span>
|
| 234 |
+
{a.agency_info ? (
|
| 235 |
+
<span className="block text-[0.75rem] text-[var(--text-muted)] mt-0.5 line-clamp-2">
|
| 236 |
+
{a.agency_info}
|
| 237 |
+
</span>
|
| 238 |
+
) : null}
|
| 239 |
+
</button>
|
| 240 |
+
))
|
| 241 |
+
)}
|
| 242 |
+
</SidebarAccordion>
|
| 243 |
+
</nav>
|
| 244 |
+
</aside>
|
| 245 |
+
);
|
| 246 |
+
}
|
codebookly/src/features/layout/SidebarAccordion.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from "react";
|
| 2 |
+
import { ChevronDown } from "lucide-react";
|
| 3 |
+
import { sidebarNavBtnClass } from "./sidebarTokens";
|
| 4 |
+
|
| 5 |
+
type Props = {
|
| 6 |
+
menuId: string;
|
| 7 |
+
label: string;
|
| 8 |
+
icon: ReactNode;
|
| 9 |
+
isNavExpanded: boolean;
|
| 10 |
+
openMenu: string | null;
|
| 11 |
+
onToggle: (menuId: string) => void;
|
| 12 |
+
collapsedTitle?: string;
|
| 13 |
+
/** e.g. max-h scroll for long lists */
|
| 14 |
+
panelClassName?: string;
|
| 15 |
+
children: ReactNode;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export function SidebarAccordion({
|
| 19 |
+
menuId,
|
| 20 |
+
label,
|
| 21 |
+
icon,
|
| 22 |
+
isNavExpanded,
|
| 23 |
+
openMenu,
|
| 24 |
+
onToggle,
|
| 25 |
+
collapsedTitle,
|
| 26 |
+
panelClassName,
|
| 27 |
+
children,
|
| 28 |
+
}: Props) {
|
| 29 |
+
const isOpen = openMenu === menuId;
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className="space-y-1">
|
| 33 |
+
<button
|
| 34 |
+
type="button"
|
| 35 |
+
onClick={() => onToggle(menuId)}
|
| 36 |
+
title={!isNavExpanded ? collapsedTitle : undefined}
|
| 37 |
+
className={sidebarNavBtnClass(isOpen && isNavExpanded, isNavExpanded)}
|
| 38 |
+
>
|
| 39 |
+
<div className="flex items-center gap-3">
|
| 40 |
+
{icon}
|
| 41 |
+
{isNavExpanded && (
|
| 42 |
+
<span className="font-semibold text-sm tracking-wide">{label}</span>
|
| 43 |
+
)}
|
| 44 |
+
</div>
|
| 45 |
+
{isNavExpanded && (
|
| 46 |
+
<ChevronDown
|
| 47 |
+
size={16}
|
| 48 |
+
className={isOpen ? "rotate-180" : "opacity-40"}
|
| 49 |
+
aria-hidden
|
| 50 |
+
/>
|
| 51 |
+
)}
|
| 52 |
+
</button>
|
| 53 |
+
|
| 54 |
+
{isNavExpanded && isOpen && (
|
| 55 |
+
<div
|
| 56 |
+
className={`ml-4 flex flex-col gap-1 border-l border-[var(--border)] pl-4 py-1 ${panelClassName ?? ""}`}
|
| 57 |
+
>
|
| 58 |
+
{children}
|
| 59 |
+
</div>
|
| 60 |
+
)}
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
codebookly/src/features/layout/sidebarTokens.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Shared sidebar list / accordion styling */
|
| 2 |
+
export const SIDEBAR_SUB_LINK =
|
| 3 |
+
"text-sm py-2 px-3 rounded-md hover:text-[var(--primary)] hover:bg-[#334155] text-[var(--text-muted)]";
|
| 4 |
+
|
| 5 |
+
export function sidebarNavBtnClass(active: boolean, isExpanded: boolean) {
|
| 6 |
+
return `w-full flex items-center p-3 rounded-lg group
|
| 7 |
+
${active ? "bg-[#334155] text-[var(--primary)]" : "text-[var(--text-muted)] hover:bg-[#252525] hover:text-[var(--text-main)]"}
|
| 8 |
+
${isExpanded ? "justify-between" : "justify-center"}`;
|
| 9 |
+
}
|
codebookly/src/hooks/useCodebookApp.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { codeService } from "../services/codeApi";
|
| 3 |
+
import type {
|
| 4 |
+
Agency,
|
| 5 |
+
CodeRecord,
|
| 6 |
+
CommitteeDesignation,
|
| 7 |
+
SectionChapterMetadata,
|
| 8 |
+
} from "../types/codebook";
|
| 9 |
+
|
| 10 |
+
export type MainView = "codes" | "definitions";
|
| 11 |
+
|
| 12 |
+
function clearBrowseMeta(
|
| 13 |
+
setSection: (v: SectionChapterMetadata | null) => void,
|
| 14 |
+
setDesignation: (v: SectionChapterMetadata | null) => void,
|
| 15 |
+
setAgency: (v: SectionChapterMetadata | null) => void
|
| 16 |
+
) {
|
| 17 |
+
setSection(null);
|
| 18 |
+
setDesignation(null);
|
| 19 |
+
setAgency(null);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Shell state: navigation data, codes list, and handlers. Bootstrap uses one
|
| 24 |
+
* parallel round-trip (Promise.allSettled) instead of sequential fetches.
|
| 25 |
+
*/
|
| 26 |
+
export function useCodebookApp() {
|
| 27 |
+
const browseRequestIdRef = useRef(0);
|
| 28 |
+
const [mainView, setMainView] = useState<MainView>("codes");
|
| 29 |
+
const [codes, setCodes] = useState<CodeRecord[]>([]);
|
| 30 |
+
const [sectionChapterMeta, setSectionChapterMeta] =
|
| 31 |
+
useState<SectionChapterMetadata | null>(null);
|
| 32 |
+
const [designationMeta, setDesignationMeta] =
|
| 33 |
+
useState<SectionChapterMetadata | null>(null);
|
| 34 |
+
const [agencyMeta, setAgencyMeta] = useState<SectionChapterMetadata | null>(
|
| 35 |
+
null
|
| 36 |
+
);
|
| 37 |
+
const [sections, setSections] = useState<{ section: string; title: string }[]>(
|
| 38 |
+
[]
|
| 39 |
+
);
|
| 40 |
+
const [chapters, setChapters] = useState<{ chapter: string; title: string }[]>(
|
| 41 |
+
[]
|
| 42 |
+
);
|
| 43 |
+
const [designations, setDesignations] = useState<CommitteeDesignation[]>([]);
|
| 44 |
+
const [agencies, setAgencies] = useState<Agency[]>([]);
|
| 45 |
+
const [initialBrowseLoading, setInitialBrowseLoading] = useState(true);
|
| 46 |
+
const [codesBrowseLoading, setCodesBrowseLoading] = useState(false);
|
| 47 |
+
|
| 48 |
+
const nextBrowseRequestId = useCallback(() => {
|
| 49 |
+
browseRequestIdRef.current += 1;
|
| 50 |
+
return browseRequestIdRef.current;
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
let cancelled = false;
|
| 55 |
+
(async () => {
|
| 56 |
+
try {
|
| 57 |
+
const [
|
| 58 |
+
codesResult,
|
| 59 |
+
sectionsResult,
|
| 60 |
+
chaptersResult,
|
| 61 |
+
designationsResult,
|
| 62 |
+
agenciesResult,
|
| 63 |
+
] = await Promise.allSettled([
|
| 64 |
+
codeService.getCodes(),
|
| 65 |
+
codeService.getSections(),
|
| 66 |
+
codeService.getChapters(),
|
| 67 |
+
codeService.getCommitteeDesignations(),
|
| 68 |
+
codeService.getAgencies(),
|
| 69 |
+
]);
|
| 70 |
+
if (cancelled) return;
|
| 71 |
+
|
| 72 |
+
if (codesResult.status === "fulfilled") {
|
| 73 |
+
setCodes(codesResult.value ?? []);
|
| 74 |
+
clearBrowseMeta(
|
| 75 |
+
setSectionChapterMeta,
|
| 76 |
+
setDesignationMeta,
|
| 77 |
+
setAgencyMeta
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
if (sectionsResult.status === "fulfilled") {
|
| 81 |
+
setSections(sectionsResult.value ?? []);
|
| 82 |
+
}
|
| 83 |
+
if (chaptersResult.status === "fulfilled") {
|
| 84 |
+
setChapters(chaptersResult.value ?? []);
|
| 85 |
+
}
|
| 86 |
+
if (designationsResult.status === "fulfilled") {
|
| 87 |
+
setDesignations(designationsResult.value ?? []);
|
| 88 |
+
} else {
|
| 89 |
+
setDesignations([]);
|
| 90 |
+
}
|
| 91 |
+
if (agenciesResult.status === "fulfilled") {
|
| 92 |
+
setAgencies(agenciesResult.value ?? []);
|
| 93 |
+
} else {
|
| 94 |
+
setAgencies([]);
|
| 95 |
+
}
|
| 96 |
+
} finally {
|
| 97 |
+
if (!cancelled) {
|
| 98 |
+
setInitialBrowseLoading(false);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
})();
|
| 102 |
+
return () => {
|
| 103 |
+
cancelled = true;
|
| 104 |
+
};
|
| 105 |
+
}, []);
|
| 106 |
+
|
| 107 |
+
const handleSectionSelect = useCallback(
|
| 108 |
+
async (sectionCode: string) => {
|
| 109 |
+
const reqId = nextBrowseRequestId();
|
| 110 |
+
setMainView("codes");
|
| 111 |
+
setCodesBrowseLoading(true);
|
| 112 |
+
setCodes([]);
|
| 113 |
+
setSectionChapterMeta(null);
|
| 114 |
+
setDesignationMeta(null);
|
| 115 |
+
setAgencyMeta(null);
|
| 116 |
+
try {
|
| 117 |
+
const data = await codeService.getSectionData(sectionCode);
|
| 118 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 119 |
+
setCodes(data.codes ?? []);
|
| 120 |
+
const cm = data.chapter_metadata;
|
| 121 |
+
setSectionChapterMeta(
|
| 122 |
+
cm
|
| 123 |
+
? { title: cm.title, description: cm.description ?? "" }
|
| 124 |
+
: null
|
| 125 |
+
);
|
| 126 |
+
} catch {
|
| 127 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 128 |
+
setCodes([]);
|
| 129 |
+
setSectionChapterMeta(null);
|
| 130 |
+
} finally {
|
| 131 |
+
if (browseRequestIdRef.current === reqId) {
|
| 132 |
+
setCodesBrowseLoading(false);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
[nextBrowseRequestId]
|
| 137 |
+
);
|
| 138 |
+
|
| 139 |
+
const handleDesignationSelect = useCallback(
|
| 140 |
+
async (d: CommitteeDesignation) => {
|
| 141 |
+
const reqId = nextBrowseRequestId();
|
| 142 |
+
setMainView("codes");
|
| 143 |
+
setCodesBrowseLoading(true);
|
| 144 |
+
setCodes([]);
|
| 145 |
+
setSectionChapterMeta(null);
|
| 146 |
+
setAgencyMeta(null);
|
| 147 |
+
setDesignationMeta({
|
| 148 |
+
title: d.letter_tag,
|
| 149 |
+
description: d.description ?? "",
|
| 150 |
+
});
|
| 151 |
+
try {
|
| 152 |
+
const list = await codeService.getCodesByDesignation(d.letter_tag);
|
| 153 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 154 |
+
setCodes(list ?? []);
|
| 155 |
+
} catch {
|
| 156 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 157 |
+
setCodes([]);
|
| 158 |
+
} finally {
|
| 159 |
+
if (browseRequestIdRef.current === reqId) {
|
| 160 |
+
setCodesBrowseLoading(false);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
[nextBrowseRequestId]
|
| 165 |
+
);
|
| 166 |
+
|
| 167 |
+
const handleAgencySelect = useCallback(
|
| 168 |
+
async (a: Agency) => {
|
| 169 |
+
const reqId = nextBrowseRequestId();
|
| 170 |
+
setMainView("codes");
|
| 171 |
+
setCodesBrowseLoading(true);
|
| 172 |
+
setCodes([]);
|
| 173 |
+
setSectionChapterMeta(null);
|
| 174 |
+
setDesignationMeta(null);
|
| 175 |
+
setAgencyMeta({
|
| 176 |
+
title: a.agency,
|
| 177 |
+
description: a.agency_info ?? "",
|
| 178 |
+
});
|
| 179 |
+
try {
|
| 180 |
+
const enriched = await codeService.getCodesByAgency(a.agency);
|
| 181 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 182 |
+
setCodes((enriched ?? []).map((row) => row.code));
|
| 183 |
+
} catch {
|
| 184 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 185 |
+
setCodes([]);
|
| 186 |
+
} finally {
|
| 187 |
+
if (browseRequestIdRef.current === reqId) {
|
| 188 |
+
setCodesBrowseLoading(false);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
},
|
| 192 |
+
[nextBrowseRequestId]
|
| 193 |
+
);
|
| 194 |
+
|
| 195 |
+
const handleChapterSelect = useCallback(
|
| 196 |
+
async (chapterKey: string) => {
|
| 197 |
+
const reqId = nextBrowseRequestId();
|
| 198 |
+
setMainView("codes");
|
| 199 |
+
setCodesBrowseLoading(true);
|
| 200 |
+
setCodes([]);
|
| 201 |
+
clearBrowseMeta(
|
| 202 |
+
setSectionChapterMeta,
|
| 203 |
+
setDesignationMeta,
|
| 204 |
+
setAgencyMeta
|
| 205 |
+
);
|
| 206 |
+
try {
|
| 207 |
+
const meta = await codeService.searchChapterOrAppendix(chapterKey);
|
| 208 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 209 |
+
const id =
|
| 210 |
+
meta.chapter != null && meta.chapter !== ""
|
| 211 |
+
? String(meta.chapter)
|
| 212 |
+
: meta.appendix != null && meta.appendix !== ""
|
| 213 |
+
? String(meta.appendix)
|
| 214 |
+
: chapterKey;
|
| 215 |
+
const list = await codeService.getCodesByChapter(id);
|
| 216 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 217 |
+
setCodes(list ?? []);
|
| 218 |
+
} catch {
|
| 219 |
+
if (browseRequestIdRef.current !== reqId) return;
|
| 220 |
+
setCodes([]);
|
| 221 |
+
} finally {
|
| 222 |
+
if (browseRequestIdRef.current === reqId) {
|
| 223 |
+
setCodesBrowseLoading(false);
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
[nextBrowseRequestId]
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
const codesAreaLoading = initialBrowseLoading || codesBrowseLoading;
|
| 231 |
+
|
| 232 |
+
return {
|
| 233 |
+
mainView,
|
| 234 |
+
setMainView,
|
| 235 |
+
codes,
|
| 236 |
+
codesAreaLoading,
|
| 237 |
+
sectionChapterMeta,
|
| 238 |
+
designationMeta,
|
| 239 |
+
agencyMeta,
|
| 240 |
+
sections,
|
| 241 |
+
chapters,
|
| 242 |
+
designations,
|
| 243 |
+
agencies,
|
| 244 |
+
handleSectionSelect,
|
| 245 |
+
handleChapterSelect,
|
| 246 |
+
handleDesignationSelect,
|
| 247 |
+
handleAgencySelect,
|
| 248 |
+
};
|
| 249 |
+
}
|
codebookly/src/index.css
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
/* Keep your variables, but remove the tag selectors like h1 { font-size: 56px } */
|
| 4 |
+
:root {
|
| 5 |
+
--text: #6b6375;
|
| 6 |
+
--text-h: #08060d;
|
| 7 |
+
--bg: #fff;
|
| 8 |
+
--border: #e5e4e7;
|
| 9 |
+
--accent: #aa3bff;
|
| 10 |
+
/* ... keep other vars ... */
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
@media (prefers-color-scheme: dark) {
|
| 14 |
+
:root {
|
| 15 |
+
--text: #9ca3af;
|
| 16 |
+
--text-h: #f3f4f6;
|
| 17 |
+
--bg: #16171d;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@theme {
|
| 22 |
+
--color-primary: #3b82f6;
|
| 23 |
+
--color-card: #1e1e1e;
|
| 24 |
+
--color-border-ui: #334155;
|
| 25 |
+
--color-text-main: #ececec;
|
| 26 |
+
--color-text-muted: #94a3b8;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
body {
|
| 30 |
+
margin: 0;
|
| 31 |
+
background-color: var(--bg);
|
| 32 |
+
color: var(--text);
|
| 33 |
+
font-family: system-ui, sans-serif;
|
| 34 |
+
}
|
codebookly/src/main.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import { CODEBOOKLY_ICON_SRC } from "./branding";
|
| 4 |
+
import "./index.css";
|
| 5 |
+
import App from "./App.tsx";
|
| 6 |
+
|
| 7 |
+
{
|
| 8 |
+
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
| 9 |
+
if (!link) {
|
| 10 |
+
link = document.createElement("link");
|
| 11 |
+
link.rel = "icon";
|
| 12 |
+
document.head.appendChild(link);
|
| 13 |
+
}
|
| 14 |
+
link.type = "image/webp";
|
| 15 |
+
link.href = CODEBOOKLY_ICON_SRC;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
createRoot(document.getElementById("root")!).render(
|
| 19 |
+
<StrictMode>
|
| 20 |
+
<App />
|
| 21 |
+
</StrictMode>,
|
| 22 |
+
);
|
codebookly/src/services/apiClient.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from "axios";
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = "http://127.0.0.1:8000";
|
| 4 |
+
|
| 5 |
+
export const apiClient = axios.create({
|
| 6 |
+
baseURL: API_BASE_URL,
|
| 7 |
+
});
|
codebookly/src/services/apiService.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Barrel re-exports — import from `codeApi` / `definitionsApi` for clearer boundaries.
|
| 3 |
+
*/
|
| 4 |
+
export { apiClient } from "./apiClient";
|
| 5 |
+
export {
|
| 6 |
+
codeService,
|
| 7 |
+
CODES_BY_AGENCY_LIMIT,
|
| 8 |
+
CODES_BY_DESIGNATION_LIMIT,
|
| 9 |
+
} from "./codeApi";
|
| 10 |
+
export { definitionsService, type DefinitionsPageParams } from "./definitionsApi";
|
codebookly/src/services/codeApi.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
Agency,
|
| 3 |
+
CodeRecord,
|
| 4 |
+
CommitteeDesignation,
|
| 5 |
+
EnrichedCode,
|
| 6 |
+
SectionDataResponse,
|
| 7 |
+
SingleCodeResponse,
|
| 8 |
+
} from "../types/codebook";
|
| 9 |
+
import { apiClient } from "./apiClient";
|
| 10 |
+
|
| 11 |
+
/** Server default is 10; use a higher cap until the API paginates. */
|
| 12 |
+
export const CODES_BY_DESIGNATION_LIMIT = 2000;
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* get-codes-by-agency runs full enrichment per row on the server — keep moderate
|
| 16 |
+
* until the API returns plain codes or paginates.
|
| 17 |
+
*/
|
| 18 |
+
export const CODES_BY_AGENCY_LIMIT = 150;
|
| 19 |
+
|
| 20 |
+
export const codeService = {
|
| 21 |
+
getCodes: () => apiClient.get<CodeRecord[]>("/api/codes").then((res) => res.data),
|
| 22 |
+
getChapters: () => apiClient.get("/api/chapters").then((res) => res.data),
|
| 23 |
+
getSections: () =>
|
| 24 |
+
apiClient.get("/api/list-sections").then((res) => res.data),
|
| 25 |
+
getSectionData: (section: string) =>
|
| 26 |
+
apiClient
|
| 27 |
+
.get<SectionDataResponse>(
|
| 28 |
+
`/api/sections/${encodeURIComponent(section)}`
|
| 29 |
+
)
|
| 30 |
+
.then((res) => res.data),
|
| 31 |
+
searchChapterOrAppendix: (search: string) =>
|
| 32 |
+
apiClient
|
| 33 |
+
.get(`/api/search-chapter-or-appendix/${encodeURIComponent(search)}`)
|
| 34 |
+
.then((res) => res.data),
|
| 35 |
+
getCodesByChapter: (chapter: string) =>
|
| 36 |
+
apiClient
|
| 37 |
+
.get<CodeRecord[]>(
|
| 38 |
+
`/api/get-codes-by-chapter/${encodeURIComponent(chapter)}`
|
| 39 |
+
)
|
| 40 |
+
.then((res) => res.data),
|
| 41 |
+
getCommitteeDesignations: () =>
|
| 42 |
+
apiClient
|
| 43 |
+
.get<CommitteeDesignation[]>("/api/committee-designations")
|
| 44 |
+
.then((res) => res.data),
|
| 45 |
+
getCodesByDesignation: (
|
| 46 |
+
designation: string,
|
| 47 |
+
limit = CODES_BY_DESIGNATION_LIMIT
|
| 48 |
+
) =>
|
| 49 |
+
apiClient
|
| 50 |
+
.get<CodeRecord[]>(
|
| 51 |
+
`/api/get-codes-by-designation/${encodeURIComponent(designation)}`,
|
| 52 |
+
{ params: { limit } }
|
| 53 |
+
)
|
| 54 |
+
.then((res) => res.data),
|
| 55 |
+
getAgencies: () =>
|
| 56 |
+
apiClient.get<Agency[]>("/api/agencies").then((res) => res.data),
|
| 57 |
+
getCodesByAgency: (agency: string, limit = CODES_BY_AGENCY_LIMIT) =>
|
| 58 |
+
apiClient
|
| 59 |
+
.get<EnrichedCode[]>(
|
| 60 |
+
`/api/get-codes-by-agency/${encodeURIComponent(agency)}`,
|
| 61 |
+
{ params: { limit } }
|
| 62 |
+
)
|
| 63 |
+
.then((res) => res.data),
|
| 64 |
+
getCode: (code: string) =>
|
| 65 |
+
apiClient
|
| 66 |
+
.get<SingleCodeResponse>(`/api/code/${encodeURIComponent(code)}`)
|
| 67 |
+
.then((res) => res.data),
|
| 68 |
+
getFullCodeContext: (code: string) =>
|
| 69 |
+
apiClient
|
| 70 |
+
.get<EnrichedCode>(
|
| 71 |
+
`/api/codes-full-context/${encodeURIComponent(code)}`
|
| 72 |
+
)
|
| 73 |
+
.then((res) => res.data),
|
| 74 |
+
};
|
codebookly/src/services/definitionsApi.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { DefinitionPageResponse } from "../types/definitions";
|
| 2 |
+
import { apiClient } from "./apiClient";
|
| 3 |
+
|
| 4 |
+
export type DefinitionsPageParams = {
|
| 5 |
+
page?: number;
|
| 6 |
+
page_size?: number;
|
| 7 |
+
q?: string;
|
| 8 |
+
letter_tag?: string;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export const definitionsService = {
|
| 12 |
+
getPage: (params: DefinitionsPageParams, config?: { signal?: AbortSignal }) =>
|
| 13 |
+
apiClient
|
| 14 |
+
.get<DefinitionPageResponse>("/api/definitions", {
|
| 15 |
+
params: {
|
| 16 |
+
page: params.page ?? 1,
|
| 17 |
+
page_size: params.page_size ?? 24,
|
| 18 |
+
q: params.q,
|
| 19 |
+
letter_tag: params.letter_tag,
|
| 20 |
+
},
|
| 21 |
+
signal: config?.signal,
|
| 22 |
+
})
|
| 23 |
+
.then((res) => res.data),
|
| 24 |
+
|
| 25 |
+
downloadExport: async (params: { q?: string; letter_tag?: string }) => {
|
| 26 |
+
const res = await apiClient.get<Blob>("/api/definitions/export", {
|
| 27 |
+
params: {
|
| 28 |
+
q: params.q,
|
| 29 |
+
letter_tag: params.letter_tag,
|
| 30 |
+
},
|
| 31 |
+
responseType: "blob",
|
| 32 |
+
});
|
| 33 |
+
const url = URL.createObjectURL(res.data);
|
| 34 |
+
const a = document.createElement("a");
|
| 35 |
+
a.href = url;
|
| 36 |
+
a.download = `definitions-export-${new Date().toISOString().slice(0, 10)}.json`;
|
| 37 |
+
a.click();
|
| 38 |
+
URL.revokeObjectURL(url);
|
| 39 |
+
},
|
| 40 |
+
};
|
codebookly/src/types/code.d.ts
ADDED
|
File without changes
|
codebookly/src/types/codebook.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Shipped with GET /api/sections/{section} as `chapter_metadata` */
|
| 2 |
+
export interface SectionChapterMetadata {
|
| 3 |
+
title: string;
|
| 4 |
+
description: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
/** Mirrors backend `schemas.Code` */
|
| 8 |
+
export interface CodeRecord {
|
| 9 |
+
entry_type: string;
|
| 10 |
+
letter_tag: string;
|
| 11 |
+
code: string;
|
| 12 |
+
chapter: string;
|
| 13 |
+
parent_code: string;
|
| 14 |
+
root_code: string;
|
| 15 |
+
title: string;
|
| 16 |
+
figure: string;
|
| 17 |
+
table: string;
|
| 18 |
+
content: string;
|
| 19 |
+
section_code: string;
|
| 20 |
+
section_title: string;
|
| 21 |
+
sort_index: number;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** GET /api/sections/{section} */
|
| 25 |
+
export interface SectionDataResponse {
|
| 26 |
+
chapter_metadata: SectionChapterMetadata;
|
| 27 |
+
codes: CodeRecord[];
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** GET /api/code/{code} */
|
| 31 |
+
export interface SingleCodeResponse {
|
| 32 |
+
chapter_metadata: SectionChapterMetadata;
|
| 33 |
+
code: CodeRecord;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export interface ChapterInfo {
|
| 37 |
+
title: string;
|
| 38 |
+
description: string;
|
| 39 |
+
about: string;
|
| 40 |
+
chapter: string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Subset returned by full-context endpoint (agency, standard_id, definition). */
|
| 44 |
+
export interface StandardRef {
|
| 45 |
+
agency: string;
|
| 46 |
+
standard_id: string;
|
| 47 |
+
definition: string;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export interface Agency {
|
| 51 |
+
agency: string;
|
| 52 |
+
agency_info: string;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface CommitteeDesignation {
|
| 56 |
+
letter_tag: string;
|
| 57 |
+
description: string;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface IndexReference {
|
| 61 |
+
id: number;
|
| 62 |
+
term: string;
|
| 63 |
+
label: string;
|
| 64 |
+
ref_id: string;
|
| 65 |
+
ref_type: string;
|
| 66 |
+
breadcrumb: string;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export interface EnrichedCode {
|
| 70 |
+
code: CodeRecord;
|
| 71 |
+
chapter_info: ChapterInfo;
|
| 72 |
+
standards: StandardRef[];
|
| 73 |
+
committee_designations: CommitteeDesignation[];
|
| 74 |
+
index_terms: IndexReference[];
|
| 75 |
+
}
|
codebookly/src/types/constants.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const COLORS = {
|
| 2 |
+
primary: "#3b82f6",
|
| 3 |
+
textMain: "#ececec",
|
| 4 |
+
textMuted: "#94a3b8",
|
| 5 |
+
bgCard: "#1e1e1e",
|
| 6 |
+
border: "#334155",
|
| 7 |
+
} as const;
|
codebookly/src/types/definitions.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface DefinitionEntry {
|
| 2 |
+
definition: string;
|
| 3 |
+
term: string;
|
| 4 |
+
letter_tag: string;
|
| 5 |
+
committee_designation: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface DefinitionPageResponse {
|
| 9 |
+
items: DefinitionEntry[];
|
| 10 |
+
total: number;
|
| 11 |
+
page: number;
|
| 12 |
+
page_size: number;
|
| 13 |
+
total_pages: number;
|
| 14 |
+
}
|
codebookly/src/types/navbar.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type NavbarBrandConfig = {
|
| 2 |
+
title: string;
|
| 3 |
+
href: string;
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export type NavbarBrowseOption = {
|
| 7 |
+
kind: string;
|
| 8 |
+
label: string;
|
| 9 |
+
itemPlaceholder: string;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export type NavbarBrowseCopy = {
|
| 13 |
+
regionAriaLabel: string;
|
| 14 |
+
typeColumnLabel: string;
|
| 15 |
+
contentTypeSelectAriaLabel: string;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type NavbarBrowseConfig = {
|
| 19 |
+
options: NavbarBrowseOption[];
|
| 20 |
+
copy: NavbarBrowseCopy;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export type NavbarProps = {
|
| 24 |
+
brand: NavbarBrandConfig;
|
| 25 |
+
browse?: NavbarBrowseConfig;
|
| 26 |
+
className?: string;
|
| 27 |
+
};
|