feat: add web client frontend with monorepo config.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
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).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2516
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
/* App-level styles — global layout only */
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Footer from "./components/Footer";
|
||||
import Home from "./pages/Home";
|
||||
import Standards from "./pages/Standards";
|
||||
import Categories from "./pages/Categories";
|
||||
import About from "./pages/About";
|
||||
import Recommend from "./pages/Recommend";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/standards" element={<Standards />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/recommend" element={<Recommend />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const BASE = "/api";
|
||||
|
||||
// Safe fetch: always reads body as text first, then parses JSON.
|
||||
// If the server returns HTML (e.g. 404 from a stale process or proxy miss),
|
||||
// this surfaces a clear error instead of "Unexpected token '<'".
|
||||
async function safeFetch(url, options = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, options);
|
||||
} catch (networkErr) {
|
||||
throw new Error(`Network error — is the server running? (${networkErr.message})`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
// Try JSON parse
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
// Server returned non-JSON (HTML error page, proxy 502, etc.)
|
||||
const preview = text.slice(0, 120).replace(/<[^>]+>/g, "").trim();
|
||||
throw new Error(
|
||||
`Server returned ${res.status} ${res.statusText}` +
|
||||
(preview ? `: ${preview}` : "")
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchStandards({ q = "", category = "", page = 1, limit = 18 } = {}) {
|
||||
const params = new URLSearchParams({ q, category, page, limit });
|
||||
return safeFetch(`${BASE}/standards?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchStandard(id) {
|
||||
return safeFetch(`${BASE}/standards/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function fetchCategories() {
|
||||
return safeFetch(`${BASE}/categories`);
|
||||
}
|
||||
|
||||
export async function fetchStats() {
|
||||
return safeFetch(`${BASE}/stats`);
|
||||
}
|
||||
|
||||
export async function askQuestion({ standard_id, question }) {
|
||||
return safeFetch(`${BASE}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ standard_id, question }),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/recommend — hybrid retrieval + LLM explanations
|
||||
export async function recommend({ query, top_n = 5, rewrite = false } = {}) {
|
||||
return safeFetch(`${BASE}/recommend`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, top_n, rewrite }),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/ask — chunk-grounded QA for a specific standard
|
||||
export async function askGrounded({ standard_id, question } = {}) {
|
||||
return safeFetch(`${BASE}/ask`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ standard_id, question }),
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,52 @@
|
||||
.footer {
|
||||
background: var(--parchment);
|
||||
border-top: 1px solid var(--divider);
|
||||
padding: 56px 0 28px;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
.footer-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.footer-brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.footer-tagline { font-size: 13px; color: var(--ink-48); line-height: 1.5; }
|
||||
.footer-heading { font-size: 13px; font-weight: 600; color: var(--ink-80); margin-bottom: 10px; }
|
||||
.footer-link {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--ink-48);
|
||||
text-decoration: none;
|
||||
line-height: 2.2;
|
||||
transition: color .12s;
|
||||
}
|
||||
.footer-link:hover { color: var(--accent); }
|
||||
.footer-legal {
|
||||
border-top: 1px solid var(--divider);
|
||||
padding-top: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-48);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.footer-legal p + p { margin-top: 4px; }
|
||||
.legal-link { color: var(--ink-48); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.legal-link:hover { color: var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.footer-cols { grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.footer-cols { grid-template-columns: 1fr; }
|
||||
.footer-inner { padding: 0 20px; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./Footer.css";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer" role="contentinfo">
|
||||
<div className="footer-inner">
|
||||
<div className="footer-cols">
|
||||
<div className="footer-col">
|
||||
<p className="footer-brand">BIS SP‑21</p>
|
||||
<p className="footer-tagline">
|
||||
Handbook on Building Materials<br />
|
||||
Special Publication 21 : 2005
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<p className="footer-heading">Portal</p>
|
||||
<Link className="footer-link" to="/standards">Search Standards</Link>
|
||||
<Link className="footer-link" to="/categories">Browse Categories</Link>
|
||||
<Link className="footer-link" to="/about">About</Link>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<p className="footer-heading">Bureau of Indian Standards</p>
|
||||
<a className="footer-link" href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer">BIS Official Website</a>
|
||||
<a className="footer-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">Manak Online</a>
|
||||
<a className="footer-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">Standards Portal</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<p className="footer-heading">Ministry</p>
|
||||
<a className="footer-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">DPIIT</a>
|
||||
<a className="footer-link" href="https://www.india.gov.in" target="_blank" rel="noopener noreferrer">National Portal</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-legal">
|
||||
<p>© Bureau of Indian Standards, Ministry of Commerce & Industry, Government of India. All rights reserved.</p>
|
||||
<p>Content sourced from BIS Special Publication 21 : 2005. For official standards, refer to{" "}
|
||||
<a href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer" className="legal-link">bis.gov.in</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.global-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
background: var(--surface-black);
|
||||
height: var(--nav-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
.nav-inner {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-lg);
|
||||
}
|
||||
.nav-emblem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.emblem-icon { width: 28px; height: 28px; }
|
||||
.nav-brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--on-dark);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-link {
|
||||
font-family: var(--font-text);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.12px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.nav-link:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.nav-link.active { color: #fff; }
|
||||
|
||||
.nav-hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-hamburger span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--on-dark);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.mobile-menu {
|
||||
position: sticky;
|
||||
top: var(--nav-h);
|
||||
z-index: 190;
|
||||
background: var(--tile-dark-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.mobile-link {
|
||||
display: block;
|
||||
padding: 14px 32px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
transition: background .15s;
|
||||
}
|
||||
.mobile-link:hover { background: rgba(255,255,255,0.05); }
|
||||
|
||||
@media (max-width: 833px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.nav-inner { padding: 0 20px; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import "./Navbar.css";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: "Standards", to: "/standards" },
|
||||
{ label: "Categories", to: "/categories" },
|
||||
{ label: "✦ AI Recommend", to: "/recommend" },
|
||||
{ label: "About", to: "/about" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="global-nav" role="navigation" aria-label="Primary navigation">
|
||||
<div className="nav-inner">
|
||||
<Link className="nav-emblem" to="/" aria-label="BIS SP-21 home" onClick={() => setOpen(false)}>
|
||||
<BISIcon />
|
||||
<span className="nav-brand">BIS SP‑21</span>
|
||||
</Link>
|
||||
|
||||
<div className="nav-links" role="list">
|
||||
{NAV_LINKS.map(({ label, to }) => (
|
||||
<Link
|
||||
key={to}
|
||||
className={`nav-link${pathname === to ? " active" : ""}`}
|
||||
to={to}
|
||||
role="listitem"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
className="nav-link"
|
||||
href="https://www.bis.gov.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="listitem"
|
||||
>
|
||||
BIS Portal ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="nav-hamburger"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{open && (
|
||||
<div className="mobile-menu" id="mobile-menu" role="menu">
|
||||
{NAV_LINKS.map(({ label, to }) => (
|
||||
<Link
|
||||
key={to}
|
||||
className="mobile-link"
|
||||
to={to}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
className="mobile-link"
|
||||
href="https://www.bis.gov.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
BIS Portal ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BISIcon() {
|
||||
return (
|
||||
<svg className="emblem-icon" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<circle cx="18" cy="18" r="16" stroke="#FF9933" strokeWidth="2.5" />
|
||||
<circle cx="18" cy="18" r="6" fill="#FF9933" />
|
||||
<path d="M18 4v4M18 28v4M4 18h4M28 18h4" stroke="#FF9933" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M8.7 8.7l2.8 2.8M24.5 24.5l2.8 2.8M8.7 27.3l2.8-2.8M24.5 11.5l2.8-2.8"
|
||||
stroke="#FF9933" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.result-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--sp-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.result-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 24px rgba(212,83,10,0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.result-card:active { transform: scale(.98); }
|
||||
.result-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.card-cat {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--chip-text);
|
||||
background: var(--chip-bg);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px 10px;
|
||||
margin-bottom: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
.card-id {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-summary {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-48);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
.card-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.keyword-chip {
|
||||
font-size: 11px;
|
||||
color: var(--ink-80);
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
.card-sections-count { font-size: 12px; color: var(--ink-48); }
|
||||
.card-arrow { color: var(--accent); font-size: 14px; font-weight: 600; }
|
||||
@@ -0,0 +1,36 @@
|
||||
import "./StandardCard.css";
|
||||
|
||||
export default function StandardCard({ standard, onClick }) {
|
||||
const sectionCount = Object.keys(standard.key_sections || {}).length;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="result-card"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick()}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`View details for ${standard.standard_id}`}
|
||||
>
|
||||
<span className="card-cat">{standard.category}</span>
|
||||
<p className="card-id">{standard.standard_id}</p>
|
||||
<h3 className="card-title">{standard.title}</h3>
|
||||
{standard.summary && (
|
||||
<p className="card-summary">{standard.summary}</p>
|
||||
)}
|
||||
{standard.keywords?.length > 0 && (
|
||||
<div className="card-keywords" aria-label="Keywords">
|
||||
{standard.keywords.slice(0, 5).map((kw) => (
|
||||
<span className="keyword-chip" key={kw}>{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<span className="card-sections-count">
|
||||
{sectionCount} section{sectionCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="card-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
background: rgba(13,17,23,0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
animation: fadeIn .18s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
background: var(--canvas);
|
||||
border-radius: var(--r-lg);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp .2s ease;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 28px 28px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--canvas);
|
||||
z-index: 1;
|
||||
}
|
||||
.modal-eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--chip-text);
|
||||
background: var(--chip-bg);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal-id {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-48);
|
||||
}
|
||||
.modal-close {
|
||||
flex-shrink: 0;
|
||||
background: var(--parchment);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--ink-48);
|
||||
font-size: 14px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.modal-close:hover { background: var(--divider); color: var(--ink); }
|
||||
|
||||
.modal-body { padding: 24px 28px 32px; }
|
||||
.modal-section { margin-bottom: 24px; }
|
||||
.modal-section:last-child { margin-bottom: 0; }
|
||||
.modal-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-section-body {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--ink-80);
|
||||
}
|
||||
.modal-keywords { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.modal-keyword {
|
||||
font-size: 12px;
|
||||
color: var(--ink-80);
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.modal-key-sections { display: flex; flex-direction: column; gap: 16px; }
|
||||
.key-section-item {
|
||||
background: var(--parchment);
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.key-section-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.key-section-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-80);
|
||||
}
|
||||
|
||||
/* ── AI Chat Panel ── */
|
||||
.ai-panel {
|
||||
background: linear-gradient(135deg, rgba(212,83,10,0.04) 0%, rgba(26,34,48,0.04) 100%);
|
||||
border: 1px solid rgba(212,83,10,0.15);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ai-label-icon {
|
||||
color: var(--accent);
|
||||
margin-right: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Suggestion chips */
|
||||
.chat-suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.suggestion-chip {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 14px;
|
||||
font-family: var(--font-text);
|
||||
font-size: 13px;
|
||||
color: var(--ink-80);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.suggestion-chip:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(212,83,10,0.04);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Chat log */
|
||||
.chat-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.chat-bubble {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
max-width: 92%;
|
||||
}
|
||||
.chat-bubble--user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.chat-bubble--user .bubble-text {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--r-md) var(--r-md) 4px var(--r-md);
|
||||
}
|
||||
.chat-bubble--ai .bubble-text {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--divider);
|
||||
color: var(--ink-80);
|
||||
border-radius: var(--r-md) var(--r-md) var(--r-md) 4px;
|
||||
}
|
||||
.bubble-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 10px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.bubble-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Typing dots */
|
||||
.chat-bubble--loading .bubble-text { display: none; }
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--divider);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--r-md) var(--r-md) var(--r-md) 4px;
|
||||
}
|
||||
.typing-dots span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.typing-dots span:nth-child(2) { animation-delay: .2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: .5; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
/* Chat input */
|
||||
.chat-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 10px 16px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.chat-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(212,83,10,0.1);
|
||||
}
|
||||
.chat-input:disabled { opacity: 0.6; }
|
||||
.chat-input::placeholder { color: var(--body-muted); }
|
||||
|
||||
.chat-send {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--r-pill);
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-send:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.chat-send:active:not(:disabled) { transform: scale(.95); }
|
||||
.chat-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.modal-backdrop { align-items: flex-end; padding: 0; }
|
||||
.modal { border-radius: var(--r-md) var(--r-md) 0 0; max-height: 90vh; }
|
||||
.chat-form { flex-direction: column; align-items: stretch; }
|
||||
.chat-send { text-align: center; }
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { askQuestion } from "../api/standards";
|
||||
import "./StandardModal.css";
|
||||
|
||||
export default function StandardModal({ standard, onClose }) {
|
||||
const modalRef = useRef(null);
|
||||
const closeBtnRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [question, setQuestion] = useState("");
|
||||
const [messages, setMessages] = useState([]); // [{role, text}]
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [aiError, setAiError] = useState(null);
|
||||
const chatEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
closeBtnRef.current?.focus();
|
||||
const onKey = (e) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, asking]);
|
||||
|
||||
const handleBackdrop = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
const handleAsk = async (e) => {
|
||||
e.preventDefault();
|
||||
const q = question.trim();
|
||||
if (!q || asking) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", text: q }]);
|
||||
setQuestion("");
|
||||
setAsking(true);
|
||||
setAiError(null);
|
||||
|
||||
try {
|
||||
const { answer } = await askQuestion({ standard_id: standard.standard_id, question: q });
|
||||
setMessages((prev) => [...prev, { role: "ai", text: answer }]);
|
||||
} catch (err) {
|
||||
setAiError(err.message || "Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setAsking(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
};
|
||||
|
||||
if (!standard) return null;
|
||||
|
||||
const sections = Object.entries(standard.key_sections || {});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
className="modal"
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<span className="modal-eyebrow">{standard.category}</span>
|
||||
<h2 className="modal-title" id="modal-title">{standard.title}</h2>
|
||||
<span className="modal-id">{standard.standard_id}</span>
|
||||
</div>
|
||||
<button
|
||||
className="modal-close"
|
||||
ref={closeBtnRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close standard detail"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Standard detail */}
|
||||
<div className="modal-body">
|
||||
{standard.summary && (
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Summary</p>
|
||||
<p className="modal-section-body">{standard.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{standard.keywords?.length > 0 && (
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Keywords</p>
|
||||
<div className="modal-keywords">
|
||||
{standard.keywords.map((kw) => (
|
||||
<span className="modal-keyword" key={kw}>{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 0 && (
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Key Sections</p>
|
||||
<div className="modal-key-sections">
|
||||
{sections.map(([name, text]) => (
|
||||
<div className="key-section-item" key={name}>
|
||||
<p className="key-section-name">{name}</p>
|
||||
<p className="key-section-text">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI chat panel */}
|
||||
<div className="modal-section ai-panel" aria-label="Ask AI about this standard">
|
||||
<p className="modal-section-title">
|
||||
<span className="ai-label-icon" aria-hidden="true">✦</span>
|
||||
Ask AI about this standard
|
||||
</p>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="chat-log" aria-live="polite" aria-label="Conversation">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`chat-bubble chat-bubble--${m.role}`}>
|
||||
{m.role === "ai" && (
|
||||
<span className="bubble-label" aria-label="AI response">✦</span>
|
||||
)}
|
||||
<p className="bubble-text">{m.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{asking && (
|
||||
<div className="chat-bubble chat-bubble--ai chat-bubble--loading" aria-label="AI is thinking">
|
||||
<span className="bubble-label" aria-hidden="true">✦</span>
|
||||
<span className="typing-dots">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{aiError && (
|
||||
<p className="chat-error" role="alert">{aiError}</p>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !asking && (
|
||||
<div className="chat-suggestions" aria-label="Suggested questions">
|
||||
{getSuggestions(standard).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className="suggestion-chip"
|
||||
onClick={() => {
|
||||
setQuestion(s);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="chat-form" onSubmit={handleAsk} aria-label="Ask a question">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="Ask a question about this standard…"
|
||||
maxLength={500}
|
||||
disabled={asking}
|
||||
aria-label="Your question"
|
||||
/>
|
||||
<button
|
||||
className="chat-send"
|
||||
type="submit"
|
||||
disabled={!question.trim() || asking}
|
||||
aria-label="Send question"
|
||||
>
|
||||
{asking ? "…" : "Ask"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSuggestions(standard) {
|
||||
const base = [
|
||||
`What are the key requirements of ${standard.standard_id}?`,
|
||||
"What materials or tests are specified?",
|
||||
"What are the delivery or packaging specifications?",
|
||||
];
|
||||
if (standard.key_sections?.["Chemical Requirements"]) {
|
||||
base.splice(1, 0, "Summarise the chemical requirements.");
|
||||
}
|
||||
if (standard.key_sections?.["Physical Requirements"] || standard.key_sections?.["Physical Requirement"]) {
|
||||
base.splice(1, 0, "What are the physical requirements?");
|
||||
}
|
||||
return base.slice(0, 3);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce(value, delay = 300) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* ── BIS SP-21 Design Tokens ── */
|
||||
:root {
|
||||
--accent: #d4530a;
|
||||
--accent-hover: #b8460a;
|
||||
--accent-dark: #ff8c3a;
|
||||
--accent-focus: #e05a0f;
|
||||
|
||||
--navy: #003380;
|
||||
|
||||
--canvas: #ffffff;
|
||||
--parchment: #f4f4f2;
|
||||
--pearl: #fafaf8;
|
||||
--tile-dark-1: #1a2230;
|
||||
--tile-dark-2: #1e2838;
|
||||
--tile-dark-3: #161d28;
|
||||
--surface-black: #0d1117;
|
||||
|
||||
--ink: #1c1c1e;
|
||||
--ink-80: #2d2d30;
|
||||
--ink-48: #6e6e73;
|
||||
--on-dark: #ffffff;
|
||||
--on-dark-muted: #b8c0cc;
|
||||
--body-muted: #8a8a8f;
|
||||
|
||||
--hairline: #d8d8dc;
|
||||
--divider: #ebebed;
|
||||
|
||||
--chip-bg: rgba(212, 83, 10, 0.08);
|
||||
--chip-text: #b8460a;
|
||||
|
||||
--font-display: "SF Pro Display", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-text: "SF Pro Text", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
|
||||
--r-xs: 4px;
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 18px;
|
||||
--r-pill: 9999px;
|
||||
|
||||
--sp-xxs: 4px;
|
||||
--sp-xs: 8px;
|
||||
--sp-sm: 12px;
|
||||
--sp-md: 18px;
|
||||
--sp-lg: 24px;
|
||||
--sp-xl: 32px;
|
||||
--sp-xxl: 48px;
|
||||
--sp-sec: 80px;
|
||||
|
||||
--nav-h: 48px;
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.47;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Tiles (global) ── */
|
||||
.tile { width: 100%; padding: var(--sp-sec) 0; }
|
||||
.tile-light { background: var(--canvas); }
|
||||
.tile-parchment { background: var(--parchment); }
|
||||
.tile-dark { background: var(--tile-dark-1); }
|
||||
.tile-dark-2 { background: var(--tile-dark-2); }
|
||||
|
||||
.tile-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
.tile-center { text-align: center; }
|
||||
|
||||
/* ── Typography (global) ── */
|
||||
.hero-display {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(32px, 6vw, 56px);
|
||||
font-weight: 600;
|
||||
line-height: 1.07;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--on-dark);
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
.tile-eyebrow {
|
||||
font-family: var(--font-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-dark);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.display-lg {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(26px, 4vw, 40px);
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.tile-dark .display-lg,
|
||||
.tile-dark-2 .display-lg { color: var(--on-dark); }
|
||||
|
||||
.display-md {
|
||||
font-family: var(--font-text);
|
||||
font-size: clamp(22px, 3vw, 34px);
|
||||
font-weight: 600;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--on-dark);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.lead {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(17px, 2.5vw, 24px);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--on-dark-muted);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.lead-sub {
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.47;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--ink-48);
|
||||
margin: 8px 0 28px;
|
||||
}
|
||||
.body-copy {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--on-dark-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Buttons (global) ── */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.374px;
|
||||
text-decoration: none;
|
||||
padding: 11px 24px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { transform: scale(.96); }
|
||||
.btn-primary:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
|
||||
|
||||
.btn-ghost-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--accent-dark);
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1.5px solid var(--accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
}
|
||||
.btn-ghost-dark:hover { background: rgba(255,140,58,0.1); }
|
||||
.btn-ghost-dark:active { transform: scale(.96); }
|
||||
|
||||
.btn-primary-on-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-dark);
|
||||
color: var(--surface-black);
|
||||
font-family: var(--font-text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
padding: 11px 24px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-primary-on-dark:hover { background: #ffaa5c; }
|
||||
.btn-primary-on-dark:active { transform: scale(.96); }
|
||||
|
||||
/* Hero stats */
|
||||
.hero-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px 36px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.stat-num {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--on-dark-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stat-divider { width: 1px; height: 36px; background: rgba(255,255,255,0.12); }
|
||||
.desktop-only { display: inline; }
|
||||
|
||||
/* Focus */
|
||||
*:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 833px) {
|
||||
:root { --sp-sec: 56px; }
|
||||
.hero-stats { gap: 20px; padding: 16px 24px; }
|
||||
.stat-num { font-size: 22px; }
|
||||
.desktop-only { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:root { --sp-sec: 48px; }
|
||||
.tile-inner { padding: 0 20px; }
|
||||
.hero-stats { flex-direction: column; gap: 12px; padding: 16px 20px; }
|
||||
.stat-divider { width: 40px; height: 1px; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
.about-hero { padding: 72px 0 56px; }
|
||||
.about-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 56px;
|
||||
align-items: start;
|
||||
}
|
||||
.about-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--ink);
|
||||
margin: 36px 0 12px;
|
||||
}
|
||||
.about-main .about-section-title:first-child { margin-top: 0; }
|
||||
.about-body {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--ink-80);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.about-body em { font-style: italic; color: var(--ink); }
|
||||
|
||||
.about-stat-card,
|
||||
.about-links-card {
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sidebar-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-list dt { color: var(--ink-48); font-weight: 500; }
|
||||
.detail-list dd { color: var(--ink-80); font-weight: 400; }
|
||||
|
||||
.ext-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: var(--ink-80);
|
||||
text-decoration: none;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
transition: color .15s;
|
||||
}
|
||||
.ext-link:last-child { border-bottom: none; }
|
||||
.ext-link:hover { color: var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.about-content { grid-template-columns: 1fr; }
|
||||
.about-sidebar { order: -1; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.about-stat-card, .about-links-card { margin-bottom: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.about-sidebar { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import "./About.css";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<main>
|
||||
<section className="tile tile-dark about-hero" aria-labelledby="about-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">Bureau of Indian Standards</p>
|
||||
<h1 className="hero-display" id="about-heading">About BIS SP‑21</h1>
|
||||
<p className="lead">
|
||||
India's authoritative handbook on building and construction material standards.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tile tile-light" aria-label="About the publication">
|
||||
<div className="tile-inner about-content">
|
||||
<div className="about-main">
|
||||
<h2 className="about-section-title">What is SP‑21?</h2>
|
||||
<p className="about-body">
|
||||
BIS Special Publication 21 — <em>Handbook on Building Materials</em> — is a consolidated
|
||||
reference published by the Bureau of Indian Standards. It brings together all Indian
|
||||
Standards relevant to construction and building materials into a single, organised document.
|
||||
</p>
|
||||
<p className="about-body">
|
||||
The 2005 edition (the basis of this portal) spans 929 pages across 25 material sections,
|
||||
covering everything from cement and structural steel to timber, paints, sanitary fittings,
|
||||
wire ropes, and thermal insulation.
|
||||
</p>
|
||||
|
||||
<h2 className="about-section-title">Who uses it?</h2>
|
||||
<p className="about-body">
|
||||
SP‑21 is used daily by structural engineers specifying materials, architects selecting
|
||||
finishes, contractors verifying supplier compliance, quality inspectors conducting audits,
|
||||
and procurement officers evaluating bids. It is the single source of truth for which IS
|
||||
standard governs a given building product.
|
||||
</p>
|
||||
|
||||
<h2 className="about-section-title">About this portal</h2>
|
||||
<p className="about-body">
|
||||
This portal parses the SP‑21 : 2005 source document into 573 discrete IS standards with
|
||||
structured fields — standard ID, title, material category, scope summary, key sections
|
||||
(Requirements, Delivery, Manufacture, etc.), and TF-IDF keywords. Every record is
|
||||
full-text searchable and filterable by category.
|
||||
</p>
|
||||
<p className="about-body">
|
||||
The parser uses a two-pass boundary detection algorithm to split the PDF's continuous
|
||||
text into individual standards, with deduplication, section normalisation, and
|
||||
contamination detection to ensure clean, reliable data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside className="about-sidebar">
|
||||
<div className="about-stat-card">
|
||||
<h3 className="sidebar-heading">Publication Details</h3>
|
||||
<dl className="detail-list">
|
||||
<dt>Publisher</dt><dd>Bureau of Indian Standards</dd>
|
||||
<dt>Edition</dt><dd>SP 21 : 2005</dd>
|
||||
<dt>Pages</dt><dd>929</dd>
|
||||
<dt>Standards indexed</dt><dd>573</dd>
|
||||
<dt>Categories</dt><dd>25</dd>
|
||||
<dt>Ministry</dt><dd>DPIIT, Govt. of India</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="about-links-card">
|
||||
<h3 className="sidebar-heading">Official Links</h3>
|
||||
<a className="ext-link" href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer">
|
||||
<span>BIS Official Website</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<a className="ext-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">
|
||||
<span>Manak Online</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<a className="ext-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">
|
||||
<span>Standards Portal</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<a className="ext-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">
|
||||
<span>DPIIT</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.cat-hero { padding: 72px 0 56px; }
|
||||
|
||||
.cat-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.cat-page-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
grid-template-areas: "icon name" "icon count" "icon arrow";
|
||||
grid-template-columns: 48px 1fr;
|
||||
column-gap: 16px;
|
||||
row-gap: 2px;
|
||||
align-items: start;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
.cat-page-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 20px rgba(212,83,10,0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cat-page-card:active { transform: scale(.98); }
|
||||
.cat-page-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cat-page-icon {
|
||||
grid-area: icon;
|
||||
font-size: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--parchment);
|
||||
border-radius: var(--r-md);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.cat-page-name {
|
||||
grid-area: name;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
align-self: end;
|
||||
}
|
||||
.cat-page-count {
|
||||
grid-area: count;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cat-page-arrow {
|
||||
grid-area: arrow;
|
||||
font-size: 14px;
|
||||
color: var(--ink-48);
|
||||
align-self: end;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.cat-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: linear-gradient(90deg, var(--parchment) 25%, var(--divider) 50%, var(--parchment) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--divider);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cat-page-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchCategories } from "../api/standards";
|
||||
import "./Categories.css";
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
"Adhesives": "🧲",
|
||||
"Bitumen and Tar Products": "🛣️",
|
||||
"Builder's Hardware": "🔩",
|
||||
"Building Limes": "🪨",
|
||||
"Cement and Concrete": "🏗️",
|
||||
"Concrete Reinforcement": "⚙️",
|
||||
"Doors, Windows and Shutters": "🚪",
|
||||
"Electrical Installations": "⚡",
|
||||
"Floor, Wall, Roof Coverings and Finishes": "🏛️",
|
||||
"Gypsum Building Materials": "🏺",
|
||||
"Light Metal and Their Alloys": "🔧",
|
||||
"Paints, Varnishes and Allied Products": "🎨",
|
||||
"Pipes and Fittings": "🔧",
|
||||
"Sanitary Appliances and Water Fittings": "🚿",
|
||||
"Stones": "🪨",
|
||||
"Structural Shapes": "📐",
|
||||
"Structural Steels": "🏗️",
|
||||
"Thermal Insulation Materials": "🌡️",
|
||||
"Threaded Fasteners and Rivets": "🔩",
|
||||
"Timber": "🪵",
|
||||
"Water Proofing and Damp Proofing Materials": "💧",
|
||||
"Welding Electrodes and Wires": "🔌",
|
||||
"Wire Ropes and Wire Products": "🪢",
|
||||
"Wood Products": "🪵",
|
||||
"Wood Products for Building": "🏠",
|
||||
};
|
||||
|
||||
export default function Categories() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
.then(setCategories)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const total = categories.reduce((s, c) => s + c.count, 0);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="tile tile-dark cat-hero" aria-labelledby="cat-page-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">SP‑21 : 2005</p>
|
||||
<h1 className="hero-display" id="cat-page-heading">Material Categories</h1>
|
||||
<p className="lead">
|
||||
{total} standards across {categories.length} building material sections.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tile tile-light" aria-label="All categories">
|
||||
<div className="tile-inner">
|
||||
{loading ? (
|
||||
<div className="cat-skeleton">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div className="skeleton-card" key={i} aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cat-page-grid" role="list">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.name}
|
||||
className="cat-page-card"
|
||||
role="listitem"
|
||||
onClick={() => navigate(`/standards?category=${encodeURIComponent(cat.name)}`)}
|
||||
>
|
||||
<span className="cat-page-icon" aria-hidden="true">
|
||||
{CATEGORY_ICONS[cat.name] || "📋"}
|
||||
</span>
|
||||
<span className="cat-page-name">{cat.name}</span>
|
||||
<span className="cat-page-count">{cat.count} standards</span>
|
||||
<span className="cat-page-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
.hero-tile { padding: 96px 0 80px; }
|
||||
|
||||
.hero-search-form { width: 100%; max-width: 660px; margin: 0 auto 48px; }
|
||||
.hero-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1.5px solid rgba(255,255,255,0.15);
|
||||
border-radius: var(--r-pill);
|
||||
padding-right: 6px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.hero-search-wrap:focus-within {
|
||||
background: rgba(255,255,255,0.09);
|
||||
border-color: var(--accent-dark);
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-text);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--on-dark);
|
||||
padding: 14px 14px 14px 48px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.hero-search-input::placeholder { color: rgba(255,255,255,0.35); }
|
||||
.hero-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
|
||||
.hero-search-btn { font-size: 14px; padding: 9px 20px; }
|
||||
|
||||
.section-header { text-align: center; margin-bottom: 48px; }
|
||||
.section-header .lead-sub { max-width: 540px; margin: 8px auto 0; }
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.cat-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px var(--sp-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
.cat-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 20px rgba(212,83,10,0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cat-card:active { transform: scale(.98); }
|
||||
.cat-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cat-name { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.3; }
|
||||
.cat-count { font-size: 12px; color: var(--accent); font-weight: 500; }
|
||||
|
||||
.feature-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: start;
|
||||
}
|
||||
.feature-pillars {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.pillar {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: var(--r-md);
|
||||
padding: 20px;
|
||||
}
|
||||
.pillar-icon { font-size: 22px; display: block; margin-bottom: 10px; }
|
||||
.pillar-title { font-size: 14px; font-weight: 600; color: var(--on-dark); margin-bottom: 6px; }
|
||||
.pillar-body { font-size: 13px; line-height: 1.5; color: var(--on-dark-muted); }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.feature-cols { grid-template-columns: 1fr; gap: 40px; }
|
||||
}
|
||||
@media (max-width: 833px) {
|
||||
.feature-pillars { grid-template-columns: 1fr; }
|
||||
.hero-tile { padding: 64px 0 56px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hero-search-form { max-width: 100%; }
|
||||
.hero-search-wrap { flex-direction: column; border-radius: var(--r-lg); padding: 0; }
|
||||
.hero-search-input { width: 100%; padding: 14px 14px 14px 48px; }
|
||||
.hero-search-btn { width: 100%; border-radius: 0 0 var(--r-lg) var(--r-lg); text-align: center; justify-content: center; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchStats, fetchCategories } from "../api/standards";
|
||||
import "./Home.css";
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats().then(setStats).catch(() => {});
|
||||
fetchCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) navigate(`/standards?q=${encodeURIComponent(query.trim())}`);
|
||||
else navigate("/standards");
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="tile tile-dark hero-tile" aria-labelledby="hero-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">Special Publication 21 · 2005</p>
|
||||
<h1 className="hero-display" id="hero-heading">
|
||||
Handbook of<br />Building Materials
|
||||
</h1>
|
||||
<p className="lead">
|
||||
Indian Standards across 25 material categories —<br className="desktop-only" />
|
||||
searchable, categorised, and ready to reference.
|
||||
</p>
|
||||
|
||||
<form className="hero-search-form" onSubmit={handleSearch} role="search">
|
||||
<div className="hero-search-wrap">
|
||||
<SearchIcon />
|
||||
<input
|
||||
className="hero-search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search standards, e.g. Portland Cement, IS 269…"
|
||||
aria-label="Search standards"
|
||||
/>
|
||||
<button className="btn-primary hero-search-btn" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{stats && (
|
||||
<div className="hero-stats" aria-label="Key statistics">
|
||||
<div className="stat">
|
||||
<span className="stat-num">{stats.totalStandards}</span>
|
||||
<span className="stat-label">IS Standards</span>
|
||||
</div>
|
||||
<div className="stat-divider" aria-hidden="true" />
|
||||
<div className="stat">
|
||||
<span className="stat-num">{stats.totalCategories}</span>
|
||||
<span className="stat-label">Categories</span>
|
||||
</div>
|
||||
<div className="stat-divider" aria-hidden="true" />
|
||||
<div className="stat">
|
||||
<span className="stat-num">929</span>
|
||||
<span className="stat-label">Pages Indexed</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section className="tile tile-parchment" id="categories" aria-labelledby="cat-heading">
|
||||
<div className="tile-inner">
|
||||
<div className="section-header">
|
||||
<h2 className="display-lg" id="cat-heading">25 Material Categories</h2>
|
||||
<p className="lead-sub">Every building material section from SP‑21, indexed and searchable.</p>
|
||||
</div>
|
||||
<div className="category-grid" role="list">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.name}
|
||||
className="cat-card"
|
||||
role="listitem"
|
||||
onClick={() => navigate(`/standards?category=${encodeURIComponent(cat.name)}`)}
|
||||
>
|
||||
<span className="cat-name">{cat.name}</span>
|
||||
<span className="cat-count">{cat.count} standard{cat.count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About strip */}
|
||||
<section className="tile tile-dark-2" aria-labelledby="about-heading">
|
||||
<div className="tile-inner">
|
||||
<div className="feature-cols">
|
||||
<div className="feature-text">
|
||||
<p className="tile-eyebrow">About SP‑21</p>
|
||||
<h2 className="display-md" id="about-heading">
|
||||
India's Reference for Building Material Standards
|
||||
</h2>
|
||||
<p className="body-copy">
|
||||
BIS Special Publication 21 consolidates all Indian Standards relevant to building and
|
||||
construction materials — from Portland cement to wire ropes, sanitary fittings to structural
|
||||
steels. Published by the Bureau of Indian Standards, it is the authoritative handbook used
|
||||
by architects, structural engineers, contractors, and quality inspectors across India.
|
||||
</p>
|
||||
<a
|
||||
className="btn-primary-on-dark"
|
||||
href="https://www.bis.gov.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Visit BIS Portal ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="feature-pillars" role="list">
|
||||
{PILLARS.map(({ icon, title, body }) => (
|
||||
<div className="pillar" role="listitem" key={title}>
|
||||
<span className="pillar-icon" aria-hidden="true">{icon}</span>
|
||||
<h3 className="pillar-title">{title}</h3>
|
||||
<p className="pillar-body">{body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const PILLARS = [
|
||||
{ icon: "⚡", title: "Instant Retrieval", body: "Full-text search across all 573 standards with ranked results." },
|
||||
{ icon: "📐", title: "Section-Level Detail", body: "Scope, requirements, delivery conditions — all structured fields." },
|
||||
{ icon: "🗂", title: "25 Categories", body: "Organised by BIS material sections, mirroring SP‑21's own structure." },
|
||||
{ icon: "🔒", title: "Official Source", body: "Parsed directly from the BIS SP‑21 : 2005 authoritative edition." },
|
||||
];
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M14 14l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
.recommend-page { min-height: 100vh; }
|
||||
.rec-hero { padding: 72px 0 56px; }
|
||||
|
||||
/* Search */
|
||||
.rec-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.rec-search-wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(212,83,10,0.1);
|
||||
}
|
||||
.rec-search-icon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--ink-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rec-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
color: var(--ink);
|
||||
padding: 14px 48px 14px 50px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.rec-search-input::placeholder { color: var(--body-muted); }
|
||||
.rec-search-input:disabled { opacity: 0.6; }
|
||||
.rec-clear {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--ink-48);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.rec-clear:hover { background: var(--divider); color: var(--ink); }
|
||||
|
||||
.rec-options-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rewrite-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--ink-80);
|
||||
user-select: none;
|
||||
}
|
||||
.rewrite-toggle input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rewrite-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-48);
|
||||
font-weight: 400;
|
||||
}
|
||||
.rec-submit { padding: 11px 28px; }
|
||||
|
||||
/* Example queries */
|
||||
.example-queries { margin-top: 32px; }
|
||||
.example-label { font-size: 13px; color: var(--ink-48); margin-bottom: 10px; }
|
||||
.example-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.example-chip {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-80);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
text-align: left;
|
||||
}
|
||||
.example-chip:hover { border-color: var(--accent); color: var(--accent); background: rgba(212,83,10,0.04); }
|
||||
|
||||
/* Loading */
|
||||
.results-section { padding: 48px 0 64px; min-height: 40vh; }
|
||||
.loading-state { padding: 48px 0; }
|
||||
.loading-steps { display: flex; flex-direction: column; gap: 16px; max-width: 480px; }
|
||||
.loading-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--ink-80);
|
||||
animation: fadeInUp .3s ease forwards;
|
||||
}
|
||||
.loading-step--delay { animation-delay: .6s; opacity: 0; }
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.loading-step-icon { font-size: 20px; width: 28px; text-align: center; }
|
||||
.spin-icon {
|
||||
display: inline-block;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Results header */
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.results-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.results-query { font-size: 14px; color: var(--ink-48); margin-top: 4px; }
|
||||
.results-query em { font-style: italic; color: var(--ink-80); }
|
||||
|
||||
/* Latency badges */
|
||||
.latency-badge { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.lat-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 12px;
|
||||
min-width: 72px;
|
||||
}
|
||||
.lat-badge--accent { border-color: rgba(212,83,10,0.3); background: rgba(212,83,10,0.06); }
|
||||
.lat-badge--bold { border-color: var(--ink-80); }
|
||||
.lat-ms {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
}
|
||||
.lat-badge--accent .lat-ms { color: var(--accent); }
|
||||
.lat-label { font-size: 10px; color: var(--ink-48); margin-top: 3px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* Recommend cards */
|
||||
.rec-results-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.rec-card {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto 28px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
.rec-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 24px rgba(212,83,10,0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.rec-card:active { transform: scale(.99); }
|
||||
.rec-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.rec-card-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--parchment);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rec-card-body { min-width: 0; }
|
||||
.rec-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-cat {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--chip-text);
|
||||
background: var(--chip-bg);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px 10px;
|
||||
}
|
||||
.card-id {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-48);
|
||||
}
|
||||
.rec-card-section {
|
||||
font-size: 12px;
|
||||
color: var(--ink-48);
|
||||
font-style: italic;
|
||||
}
|
||||
.rec-card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* AI explanation block */
|
||||
.rec-card-explanation {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: linear-gradient(135deg, rgba(212,83,10,0.04), rgba(26,34,48,0.03));
|
||||
border: 1px solid rgba(212,83,10,0.12);
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.explanation-icon { color: var(--accent); font-size: 13px; flex-shrink: 0; margin-top: 2px; }
|
||||
.explanation-text { font-size: 14px; line-height: 1.6; color: var(--ink-80); margin: 0; }
|
||||
|
||||
.card-keywords { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.keyword-chip {
|
||||
font-size: 11px;
|
||||
color: var(--ink-80);
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.rec-card-score {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.score-num {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-48);
|
||||
line-height: 1;
|
||||
}
|
||||
.score-label { font-size: 10px; color: var(--ink-48); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
.rec-card-arrow { color: var(--accent); font-size: 16px; font-weight: 600; align-self: center; }
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px 20px;
|
||||
color: #b91c1c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 833px) {
|
||||
.rec-card { grid-template-columns: 36px 1fr 28px; }
|
||||
.rec-card-score { display: none; }
|
||||
.results-header { flex-direction: column; gap: 16px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.rec-card { grid-template-columns: 1fr; gap: 12px; }
|
||||
.rec-card-rank { width: 32px; height: 32px; font-size: 15px; }
|
||||
.rec-card-arrow { display: none; }
|
||||
.rec-options-row { flex-direction: column; align-items: flex-start; }
|
||||
.rec-submit { width: 100%; justify-content: center; }
|
||||
.example-chips { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { recommend } from "../api/standards";
|
||||
import StandardModal from "../components/StandardModal";
|
||||
import "./Recommend.css";
|
||||
|
||||
export default function Recommend() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [rewrite, setRewrite] = useState(false);
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const data = await recommend({ query: q, top_n: 5, rewrite });
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
setError(err.message || "Something went wrong. Is the server running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
"Requirements for ordinary portland cement 33 grade",
|
||||
"Specifications for structural steel in buildings",
|
||||
"Standards for pipes and fittings in plumbing",
|
||||
"Timber used in construction",
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="recommend-page">
|
||||
{/* Header tile */}
|
||||
<section className="tile tile-dark rec-hero" aria-labelledby="rec-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">Hybrid Retrieval · AI Explanation</p>
|
||||
<h1 className="hero-display" id="rec-heading">Find & Understand Standards</h1>
|
||||
<p className="lead">
|
||||
Ask a natural language question — the system retrieves the most relevant
|
||||
IS standards using dense + sparse search, then explains each in plain English.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Search tile */}
|
||||
<section className="tile tile-parchment" aria-label="Recommendation search">
|
||||
<div className="tile-inner">
|
||||
<form onSubmit={handleSubmit} role="search" aria-label="Recommend standards">
|
||||
<div className="rec-search-wrap">
|
||||
<SearchIcon />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="rec-search-input"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. What standard covers tensile strength of structural steel?"
|
||||
aria-label="Search query"
|
||||
maxLength={500}
|
||||
disabled={loading}
|
||||
/>
|
||||
{query && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
className="rec-clear"
|
||||
onClick={() => { setQuery(""); setResults(null); inputRef.current?.focus(); }}
|
||||
aria-label="Clear"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rec-options-row">
|
||||
<label className="rewrite-toggle" title="Let the AI rephrase your query into precise IS keywords before searching">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rewrite}
|
||||
onChange={(e) => setRewrite(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span>Smart query rewrite</span>
|
||||
<span className="rewrite-hint">AI refines your query before searching</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn-primary rec-submit"
|
||||
type="submit"
|
||||
disabled={!query.trim() || loading}
|
||||
>
|
||||
{loading ? <><SpinIcon /> Searching…</> : "Find Standards"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Example queries */}
|
||||
{!results && !loading && (
|
||||
<div className="example-queries" aria-label="Example queries">
|
||||
<p className="example-label">Try an example:</p>
|
||||
<div className="example-chips">
|
||||
{EXAMPLE_QUERIES.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
className="example-chip"
|
||||
onClick={() => { setQuery(q); setTimeout(() => inputRef.current?.focus(), 50); }}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Results tile */}
|
||||
{(loading || results || error) && (
|
||||
<section className="tile tile-light results-section" aria-live="polite" aria-label="Results">
|
||||
<div className="tile-inner">
|
||||
{error && (
|
||||
<div className="error-banner" role="alert">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="loading-state" aria-label="Loading results">
|
||||
<div className="loading-steps">
|
||||
<LoadingStep icon="🔍" label="Running hybrid retrieval (FAISS + BM25)…" />
|
||||
<LoadingStep icon="✦" label="Generating AI explanations…" delay />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && !loading && (
|
||||
<>
|
||||
<div className="results-header">
|
||||
<div>
|
||||
<h2 className="results-title">
|
||||
{results.standards.length} Standard{results.standards.length !== 1 ? "s" : ""} Found
|
||||
</h2>
|
||||
<p className="results-query">for: <em>{results.query}</em></p>
|
||||
</div>
|
||||
<div className="latency-badge" aria-label="Timing breakdown">
|
||||
<LatencyBadge label="Retrieval" ms={results.latency.retrieval_ms} />
|
||||
<LatencyBadge label="AI" ms={results.latency.llm_ms} accent />
|
||||
<LatencyBadge label="Total" ms={results.latency.total_ms} bold />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rec-results-list" role="list">
|
||||
{results.standards.map((s, i) => (
|
||||
<RecommendCard
|
||||
key={s.standard_id}
|
||||
standard={s}
|
||||
rank={i + 1}
|
||||
onOpen={() => setSelected(standardsFullRecord(s))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<StandardModal standard={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function RecommendCard({ standard, rank, onOpen }) {
|
||||
return (
|
||||
<article
|
||||
className="rec-card"
|
||||
role="listitem"
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => e.key === "Enter" && onOpen()}
|
||||
tabIndex={0}
|
||||
aria-label={`Rank ${rank}: ${standard.standard_id}`}
|
||||
>
|
||||
<div className="rec-card-rank" aria-hidden="true">{rank}</div>
|
||||
|
||||
<div className="rec-card-body">
|
||||
<div className="rec-card-meta">
|
||||
<span className="card-cat">{standard.category}</span>
|
||||
<span className="card-id">{standard.standard_id}</span>
|
||||
{standard.matched_section && (
|
||||
<span className="rec-card-section">§ {standard.matched_section}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="rec-card-title">{standard.title}</h3>
|
||||
|
||||
{standard.explanation && (
|
||||
<div className="rec-card-explanation" aria-label="AI explanation">
|
||||
<span className="explanation-icon" aria-hidden="true">✦</span>
|
||||
<p className="explanation-text">{standard.explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{standard.keywords?.length > 0 && (
|
||||
<div className="card-keywords" aria-label="Keywords">
|
||||
{standard.keywords.slice(0, 5).map((kw) => (
|
||||
<span className="keyword-chip" key={kw}>{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rec-card-score" aria-label={`Relevance score ${standard.score}`}>
|
||||
<span className="score-num">{(standard.score * 100).toFixed(0)}</span>
|
||||
<span className="score-label">score</span>
|
||||
</div>
|
||||
|
||||
<span className="rec-card-arrow" aria-hidden="true">→</span>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyBadge({ label, ms, accent, bold }) {
|
||||
return (
|
||||
<div className={`lat-badge${accent ? " lat-badge--accent" : ""}${bold ? " lat-badge--bold" : ""}`}>
|
||||
<span className="lat-ms">{ms}ms</span>
|
||||
<span className="lat-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingStep({ icon, label, delay }) {
|
||||
return (
|
||||
<div className={`loading-step${delay ? " loading-step--delay" : ""}`}>
|
||||
<span className="loading-step-icon" aria-hidden="true">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="rec-search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M14 14l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinIcon() {
|
||||
return <span className="spin-icon" aria-hidden="true">⟳</span>;
|
||||
}
|
||||
|
||||
// Merge recommendation result with full standard record for the modal
|
||||
function standardsFullRecord(s) {
|
||||
return {
|
||||
standard_id: s.standard_id,
|
||||
title: s.title,
|
||||
category: s.category,
|
||||
summary: s.explanation || "",
|
||||
keywords: s.keywords || [],
|
||||
key_sections: {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.standards-page { min-height: 100vh; }
|
||||
.search-tile { padding: 48px 0 40px; }
|
||||
.results-tile { padding: 32px 0 64px; min-height: 60vh; }
|
||||
|
||||
.search-form { max-width: 700px; }
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--ink-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 13px 48px 13px 44px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(212,83,10,0.1);
|
||||
}
|
||||
.search-input::placeholder { color: var(--body-muted); }
|
||||
.search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--ink-48);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.search-clear:hover { background: var(--divider); color: var(--ink); }
|
||||
|
||||
.filter-row { margin-top: 12px; }
|
||||
.category-filter {
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 240px;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.category-filter:focus { border-color: var(--accent); }
|
||||
|
||||
.results-meta {
|
||||
font-size: 13px;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.results-empty {
|
||||
text-align: center;
|
||||
padding: 64px 0;
|
||||
color: var(--ink-48);
|
||||
}
|
||||
.empty-title { font-size: 17px; font-weight: 600; color: var(--ink-80); margin-bottom: 6px; }
|
||||
.empty-sub { font-size: 14px; }
|
||||
|
||||
/* Skeleton */
|
||||
.results-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 180px;
|
||||
background: linear-gradient(90deg, var(--parchment) 25%, var(--divider) 50%, var(--parchment) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--divider);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
.page-numbers { display: flex; align-items: center; gap: 4px; }
|
||||
.page-btn {
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--ink-80);
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-btn:hover:not(:disabled):not(.active) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.page-ellipsis { font-size: 14px; color: var(--ink-48); padding: 0 4px; }
|
||||
|
||||
.error-banner {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px 20px;
|
||||
color: #b91c1c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-form { max-width: 100%; }
|
||||
.category-filter { min-width: 100%; width: 100%; }
|
||||
.pagination { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { fetchStandards, fetchCategories } from "../api/standards";
|
||||
import { useDebounce } from "../hooks/useDebounce";
|
||||
import StandardCard from "../components/StandardCard";
|
||||
import StandardModal from "../components/StandardModal";
|
||||
import "./Standards.css";
|
||||
|
||||
const PAGE_SIZE = 18;
|
||||
|
||||
export default function Standards() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||
const [category, setCategory] = useState(searchParams.get("category") || "");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [results, setResults] = useState([]);
|
||||
const [meta, setMeta] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (q, cat, pg) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchStandards({ q, category: cat, page: pg, limit: PAGE_SIZE });
|
||||
setResults(data.data);
|
||||
setMeta(data.meta);
|
||||
} catch {
|
||||
setError("Could not load standards. Is the server running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
const params = {};
|
||||
if (debouncedQuery) params.q = debouncedQuery;
|
||||
if (category) params.category = category;
|
||||
setSearchParams(params, { replace: true });
|
||||
load(debouncedQuery, category, 1);
|
||||
}, [debouncedQuery, category, load, setSearchParams]);
|
||||
|
||||
const handlePageChange = (pg) => {
|
||||
setPage(pg);
|
||||
load(debouncedQuery, category, pg);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="standards-page">
|
||||
<section className="tile tile-parchment search-tile" aria-labelledby="search-heading">
|
||||
<div className="tile-inner">
|
||||
<h1 className="display-lg" id="search-heading">Find an IS Standard</h1>
|
||||
<p className="lead-sub">Search by standard number, title, material, or keyword.</p>
|
||||
|
||||
<form
|
||||
className="search-form"
|
||||
role="search"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="search-wrap">
|
||||
<SearchIcon />
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. Ordinary Portland Cement, IS 269, aggregates…"
|
||||
aria-label="Search standards"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="search-clear"
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-row">
|
||||
<select
|
||||
className="category-filter"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.name} value={c.name}>
|
||||
{c.name} ({c.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tile tile-light results-tile" aria-live="polite" aria-atomic="false">
|
||||
<div className="tile-inner">
|
||||
{error && <div className="error-banner" role="alert">{error}</div>}
|
||||
|
||||
{!error && meta && (
|
||||
<p className="results-meta">
|
||||
{loading ? "Searching…" : `${meta.total} standard${meta.total !== 1 ? "s" : ""} found`}
|
||||
{meta.total > 0 && ` — page ${meta.page} of ${meta.totalPages}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && results.length === 0 && (
|
||||
<div className="results-skeleton">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="skeleton-card" key={i} aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length === 0 && !error && (
|
||||
<div className="results-empty">
|
||||
<p className="empty-title">No standards found</p>
|
||||
<p className="empty-sub">Try a different keyword or clear the category filter.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="results-grid">
|
||||
{results.map((s) => (
|
||||
<StandardCard
|
||||
key={s.standard_id}
|
||||
standard={s}
|
||||
onClick={() => setSelected(s)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta && meta.totalPages > 1 && (
|
||||
<nav className="pagination" aria-label="Results pagination">
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<div className="page-numbers">
|
||||
{buildPageRange(page, meta.totalPages).map((p, i) =>
|
||||
p === "…" ? (
|
||||
<span key={`ellipsis-${i}`} className="page-ellipsis">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`page-btn${p === page ? " active" : ""}`}
|
||||
onClick={() => handlePageChange(p)}
|
||||
aria-label={`Page ${p}`}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={page >= meta.totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected && (
|
||||
<StandardModal standard={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageRange(current, total) {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages = new Set([1, total, current, current - 1, current + 1].filter(p => p >= 1 && p <= total));
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
const result = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push("…");
|
||||
result.push(sorted[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M14 14l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:5000",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "bis-sp21-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"server": "node server/index.js",
|
||||
"client": "npm --prefix client run dev",
|
||||
"dev": "start cmd /k npm run server && npm run client"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user