aymie-oh commited on
Commit
55d0d9e
·
1 Parent(s): ecee171

initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +6 -0
  2. codebookly/.gitignore +24 -0
  3. codebookly/README.md +73 -0
  4. codebookly/eslint.config.js +23 -0
  5. codebookly/index.html +12 -0
  6. codebookly/package-lock.json +0 -0
  7. codebookly/package.json +34 -0
  8. codebookly/public/codebookly-icon.svg +1 -0
  9. codebookly/public/icons.svg +24 -0
  10. codebookly/src/App.css +112 -0
  11. codebookly/src/App.tsx +79 -0
  12. codebookly/src/assets/codebookly-icon.webp +0 -0
  13. codebookly/src/assets/codebookly.jpg +0 -0
  14. codebookly/src/assets/hero.png +0 -0
  15. codebookly/src/assets/react.svg +1 -0
  16. codebookly/src/assets/vite.svg +1 -0
  17. codebookly/src/branding.ts +6 -0
  18. codebookly/src/features/codes/components/CodeCard.tsx +89 -0
  19. codebookly/src/features/codes/components/CodeFullContextModal.tsx +75 -0
  20. codebookly/src/features/codes/components/CodeListSection.tsx +124 -0
  21. codebookly/src/features/codes/components/EnrichedCodeContent.tsx +107 -0
  22. codebookly/src/features/codes/components/SelectionActionBar.tsx +75 -0
  23. codebookly/src/features/codes/hooks/useCodeListSelection.ts +77 -0
  24. codebookly/src/features/codes/hooks/useEnrichedCode.ts +57 -0
  25. codebookly/src/features/codes/utils/exportCodesJson.ts +19 -0
  26. codebookly/src/features/codes/utils/exportEnrichedJson.ts +31 -0
  27. codebookly/src/features/codes/utils/fetchEnrichedForExport.ts +49 -0
  28. codebookly/src/features/codes/utils/singleCodeToEnriched.ts +18 -0
  29. codebookly/src/features/definitions/components/DefinitionRow.tsx +34 -0
  30. codebookly/src/features/definitions/components/DefinitionsExplorer.tsx +57 -0
  31. codebookly/src/features/definitions/components/DefinitionsSearchForm.tsx +77 -0
  32. codebookly/src/features/definitions/components/DefinitionsTableSection.tsx +102 -0
  33. codebookly/src/features/definitions/hooks/useDefinitionsBrowse.ts +102 -0
  34. codebookly/src/features/definitions/services/definitionsBrowse.ts +18 -0
  35. codebookly/src/features/layout/SectionChapterBanner.tsx +33 -0
  36. codebookly/src/features/layout/SideBar.tsx +246 -0
  37. codebookly/src/features/layout/SidebarAccordion.tsx +63 -0
  38. codebookly/src/features/layout/sidebarTokens.ts +9 -0
  39. codebookly/src/hooks/useCodebookApp.ts +249 -0
  40. codebookly/src/index.css +34 -0
  41. codebookly/src/main.tsx +22 -0
  42. codebookly/src/services/apiClient.ts +7 -0
  43. codebookly/src/services/apiService.ts +10 -0
  44. codebookly/src/services/codeApi.ts +74 -0
  45. codebookly/src/services/definitionsApi.ts +40 -0
  46. codebookly/src/types/code.d.ts +0 -0
  47. codebookly/src/types/codebook.ts +75 -0
  48. codebookly/src/types/constants.ts +7 -0
  49. codebookly/src/types/definitions.ts +14 -0
  50. 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
+ };