feat: add web client frontend with monorepo config.

This commit is contained in:
K
2026-04-28 23:56:23 +05:30
parent 3a0c32ea8f
commit a5cf7bbfda
37 changed files with 5505 additions and 0 deletions
+24
View File
@@ -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?
+16
View File
@@ -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.
+21
View File
@@ -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 } },
},
},
])
+13
View File
@@ -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>
+2516
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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

+24
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
/* App-level styles — global layout only */
+25
View File
@@ -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 />
</>
);
}
+77
View File
@@ -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

+1
View File
@@ -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

+52
View File
@@ -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; }
}
+43
View File
@@ -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 SP21</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>
);
}
+99
View File
@@ -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; }
}
+98
View File
@@ -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 SP21</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>
);
}
+297
View File
@@ -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; }
}
+215
View File
@@ -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);
}
+10
View File
@@ -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;
}
+254
View File
@@ -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; }
}
+13
View File
@@ -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>
);
+72
View File
@@ -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; }
}
+85
View File
@@ -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 SP21</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 SP21?</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">
SP21 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 SP21 : 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>
);
}
+83
View File
@@ -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; }
}
+91
View File
@@ -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">SP21 : 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>
);
}
+105
View File
@@ -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; }
}
+150
View File
@@ -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 SP21, 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 SP21</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 SP21's own structure." },
{ icon: "🔒", title: "Official Source", body: "Parsed directly from the BIS SP21 : 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>
);
}
+307
View File
@@ -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; }
}
+273
View File
@@ -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: {},
};
}
+154
View File
@@ -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; }
}
+216
View File
@@ -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>
);
}
+12
View File
@@ -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",
},
},
})
+10
View File
@@ -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"
}
}