feat: add react-i18next with English and Hindi locale support.

- Add i18next + react-i18next + i18next-browser-languagedetector.
- EN/HI translation files covering all UI strings across every page and component.
- Language switcher button in Navbar; choice persisted to localStorage.
- document.documentElement.lang synced to active language in App.
- Skip-nav link and #main-content anchor for keyboard accessibility.
- aria-describedby on modal dialog; page title and meta description in index.html.
- Secure page title set to 'BIS SP-21 Standards.'
This commit is contained in:
K
2026-05-03 00:01:14 +05:30
parent 0d8b2cdb3f
commit 8e1348fb63
19 changed files with 781 additions and 272 deletions
+3 -1
View File
@@ -4,9 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title> <meta name="description" content="BIS SP-21 Standards — search and explore Bureau of Indian Standards building material standards." />
<title>BIS SP21 Standards</title>
</head> </head>
<body> <body>
<a href="#main-content" class="skip-nav">Skip to main content</a>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
+103 -30
View File
@@ -8,8 +8,11 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.14.2" "react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
@@ -216,6 +219,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -664,9 +676,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -684,9 +693,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -704,9 +710,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -724,9 +727,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -744,9 +744,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -764,9 +761,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1509,6 +1503,52 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.0.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz",
"integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1780,9 +1820,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1804,9 +1841,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1828,9 +1862,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1852,9 +1883,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2152,6 +2180,33 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-i18next": {
"version": "17.0.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz",
"integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.1",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.14.2", "version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
@@ -2365,6 +2420,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
@@ -2443,6 +2507,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3
View File
@@ -10,8 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.14.2" "react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
+18 -12
View File
@@ -1,4 +1,6 @@
import { useEffect } from "react";
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import Home from "./pages/Home"; import Home from "./pages/Home";
@@ -7,22 +9,26 @@ import Categories from "./pages/Categories";
import About from "./pages/About"; import About from "./pages/About";
import Recommend from "./pages/Recommend"; import Recommend from "./pages/Recommend";
/**
* Main application router.
* Renders layout with Navbar/Footer and routes to pages.
*/
export default function App() { export default function App() {
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.lang = i18n.language;
}, [i18n.language]);
return ( return (
<> <>
<Navbar /> <Navbar />
<Routes> <div id="main-content">
<Route path="/" element={<Home />} /> <Routes>
<Route path="/standards" element={<Standards />} /> <Route path="/" element={<Home />} />
<Route path="/categories" element={<Categories />} /> <Route path="/standards" element={<Standards />} />
<Route path="/recommend" element={<Recommend />} /> <Route path="/categories" element={<Categories />} />
<Route path="/about" element={<About />} /> <Route path="/recommend" element={<Recommend />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/about" element={<About />} />
</Routes> <Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
<Footer /> <Footer />
</> </>
); );
+18 -18
View File
@@ -1,39 +1,39 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import "./Footer.css"; import "./Footer.css";
export default function Footer() { export default function Footer() {
const { t } = useTranslation();
return ( return (
<footer className="footer" role="contentinfo"> <footer className="footer" role="contentinfo">
<div className="footer-inner"> <div className="footer-inner">
<div className="footer-cols"> <div className="footer-cols">
<div className="footer-col"> <div className="footer-col">
<p className="footer-brand">BIS SP21</p> <p className="footer-brand">{t("footer.brand")}</p>
<p className="footer-tagline"> <p className="footer-tagline">{t("footer.tagline")}</p>
Handbook on Building Materials<br />
Special Publication 21 : 2005
</p>
</div> </div>
<div className="footer-col"> <div className="footer-col">
<p className="footer-heading">Portal</p> <p className="footer-heading">{t("footer.portal")}</p>
<Link className="footer-link" to="/standards">Search Standards</Link> <Link className="footer-link" to="/standards">{t("footer.searchStandards")}</Link>
<Link className="footer-link" to="/categories">Browse Categories</Link> <Link className="footer-link" to="/categories">{t("footer.browseCategories")}</Link>
<Link className="footer-link" to="/about">About</Link> <Link className="footer-link" to="/about">{t("footer.about")}</Link>
</div> </div>
<div className="footer-col"> <div className="footer-col">
<p className="footer-heading">Bureau of Indian Standards</p> <p className="footer-heading">{t("footer.bis")}</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.bis.gov.in" target="_blank" rel="noopener noreferrer">{t("footer.bisWebsite")}</a>
<a className="footer-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">Manak Online</a> <a className="footer-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">{t("footer.manakOnline")}</a>
<a className="footer-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">Standards Portal</a> <a className="footer-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">{t("footer.standardsPortal")}</a>
</div> </div>
<div className="footer-col"> <div className="footer-col">
<p className="footer-heading">Ministry</p> <p className="footer-heading">{t("footer.ministry")}</p>
<a className="footer-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">DPIIT</a> <a className="footer-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">{t("footer.dpiit")}</a>
<a className="footer-link" href="https://www.india.gov.in" target="_blank" rel="noopener noreferrer">National Portal</a> <a className="footer-link" href="https://www.india.gov.in" target="_blank" rel="noopener noreferrer">{t("footer.nationalPortal")}</a>
</div> </div>
</div> </div>
<div className="footer-legal"> <div className="footer-legal">
<p>© Bureau of Indian Standards, Ministry of Commerce & Industry, Government of India. All rights reserved.</p> <p>{t("footer.copyright")}</p>
<p>Content sourced from BIS Special Publication 21 : 2005. For official standards, refer to{" "} <p>{t("footer.sourceNote")}{" "}
<a href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer" className="legal-link">bis.gov.in</a>. <a href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer" className="legal-link">bis.gov.in</a>.
</p> </p>
</div> </div>
+14
View File
@@ -90,6 +90,20 @@
} }
.mobile-link:hover { background: rgba(255,255,255,0.05); } .mobile-link:hover { background: rgba(255,255,255,0.05); }
.nav-lang-btn {
background: none;
border: 1px solid rgba(255,255,255,0.25);
border-radius: 4px;
padding: 3px 10px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.nav-lang-btn:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(255,255,255,0.45);
}
@media (max-width: 833px) { @media (max-width: 833px) {
.nav-links { display: none; } .nav-links { display: none; }
.nav-hamburger { display: flex; } .nav-hamburger { display: flex; }
+34 -16
View File
@@ -1,28 +1,31 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import "./Navbar.css"; import "./Navbar.css";
const NAV_LINKS = [
{ label: "Standards", to: "/standards" },
{ label: "Categories", to: "/categories" },
{ label: "✦ AI Recommend", to: "/recommend" },
{ label: "About", to: "/about" },
];
/**
* Site navigation bar with responsive menu.
*/
export default function Navbar() { export default function Navbar() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { pathname } = useLocation(); const { pathname } = useLocation();
const { t, i18n } = useTranslation();
const NAV_LINKS = [
{ label: t("nav.standards"), to: "/standards" },
{ label: t("nav.categories"), to: "/categories" },
{ label: t("nav.recommend"), to: "/recommend" },
{ label: t("nav.about"), to: "/about" },
];
const toggleLang = () => {
i18n.changeLanguage(i18n.language === "en" ? "hi" : "en");
};
return ( return (
<> <>
<nav className="global-nav" role="navigation" aria-label="Primary navigation"> <nav className="global-nav" role="navigation" aria-label={t("nav.brand") + " primary navigation"}>
<div className="nav-inner"> <div className="nav-inner">
<Link className="nav-emblem" to="/" aria-label="BIS SP-21 home" onClick={() => setOpen(false)}> <Link className="nav-emblem" to="/" aria-label={t("nav.brand") + " home"} onClick={() => setOpen(false)}>
<BISIcon /> <BISIcon />
<span className="nav-brand">BIS SP21</span> <span className="nav-brand">{t("nav.brand")}</span>
</Link> </Link>
<div className="nav-links" role="list"> <div className="nav-links" role="list">
@@ -43,13 +46,21 @@ export default function Navbar() {
rel="noopener noreferrer" rel="noopener noreferrer"
role="listitem" role="listitem"
> >
BIS Portal {t("nav.bisPortal")}
</a> </a>
<button
className="nav-link nav-lang-btn"
onClick={toggleLang}
aria-label={t("lang.switchTo")}
title={t("lang.switchTo")}
>
{i18n.language === "en" ? t("lang.hi") : t("lang.en")}
</button>
</div> </div>
<button <button
className="nav-hamburger" className="nav-hamburger"
aria-label={open ? "Close menu" : "Open menu"} aria-label={open ? t("nav.closeMenu") : t("nav.openMenu")}
aria-expanded={open} aria-expanded={open}
aria-controls="mobile-menu" aria-controls="mobile-menu"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
@@ -80,8 +91,15 @@ export default function Navbar() {
role="menuitem" role="menuitem"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
BIS Portal {t("nav.bisPortal")}
</a> </a>
<button
className="mobile-link nav-lang-btn"
onClick={() => { toggleLang(); setOpen(false); }}
role="menuitem"
>
{i18n.language === "en" ? t("lang.hi") : t("lang.en")}
</button>
</div> </div>
)} )}
</> </>
+5 -3
View File
@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import "./StandardCard.css"; import "./StandardCard.css";
export default function StandardCard({ standard, onClick }) { export default function StandardCard({ standard, onClick }) {
const { t } = useTranslation();
const sectionCount = Object.keys(standard.key_sections || {}).length; const sectionCount = Object.keys(standard.key_sections || {}).length;
return ( return (
@@ -10,7 +12,7 @@ export default function StandardCard({ standard, onClick }) {
onKeyDown={(e) => e.key === "Enter" && onClick()} onKeyDown={(e) => e.key === "Enter" && onClick()}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-label={`View details for ${standard.standard_id}`} aria-label={t("card.viewDetails", { id: standard.standard_id })}
> >
<span className="card-cat">{standard.category}</span> <span className="card-cat">{standard.category}</span>
<p className="card-id">{standard.standard_id}</p> <p className="card-id">{standard.standard_id}</p>
@@ -19,7 +21,7 @@ export default function StandardCard({ standard, onClick }) {
<p className="card-summary">{standard.summary}</p> <p className="card-summary">{standard.summary}</p>
)} )}
{standard.keywords?.length > 0 && ( {standard.keywords?.length > 0 && (
<div className="card-keywords" aria-label="Keywords"> <div className="card-keywords" aria-label={t("card.keywords")}>
{standard.keywords.slice(0, 5).map((kw) => ( {standard.keywords.slice(0, 5).map((kw) => (
<span className="keyword-chip" key={kw}>{kw}</span> <span className="keyword-chip" key={kw}>{kw}</span>
))} ))}
@@ -27,7 +29,7 @@ export default function StandardCard({ standard, onClick }) {
)} )}
<div className="card-footer"> <div className="card-footer">
<span className="card-sections-count"> <span className="card-sections-count">
{sectionCount} section{sectionCount !== 1 ? "s" : ""} {t("card.section", { count: sectionCount })}
</span> </span>
<span className="card-arrow" aria-hidden="true"></span> <span className="card-arrow" aria-hidden="true"></span>
</div> </div>
+28 -25
View File
@@ -1,14 +1,16 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { askQuestion } from "../api/standards"; import { askQuestion } from "../api/standards";
import "./StandardModal.css"; import "./StandardModal.css";
export default function StandardModal({ standard, onClose }) { export default function StandardModal({ standard, onClose }) {
const { t } = useTranslation();
const modalRef = useRef(null); const modalRef = useRef(null);
const closeBtnRef = useRef(null); const closeBtnRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [messages, setMessages] = useState([]); // [{role, text}] const [messages, setMessages] = useState([]);
const [asking, setAsking] = useState(false); const [asking, setAsking] = useState(false);
const [aiError, setAiError] = useState(null); const [aiError, setAiError] = useState(null);
const chatEndRef = useRef(null); const chatEndRef = useRef(null);
@@ -46,7 +48,7 @@ export default function StandardModal({ standard, onClose }) {
const { answer } = await askQuestion({ standard_id: standard.standard_id, question: q }); const { answer } = await askQuestion({ standard_id: standard.standard_id, question: q });
setMessages((prev) => [...prev, { role: "ai", text: answer }]); setMessages((prev) => [...prev, { role: "ai", text: answer }]);
} catch (err) { } catch (err) {
setAiError(err.message || "Something went wrong. Please try again."); setAiError(err.message || t("common.serverError"));
} finally { } finally {
setAsking(false); setAsking(false);
setTimeout(() => inputRef.current?.focus(), 50); setTimeout(() => inputRef.current?.focus(), 50);
@@ -69,6 +71,7 @@ export default function StandardModal({ standard, onClose }) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="modal-title" aria-labelledby="modal-title"
aria-describedby="modal-summary"
tabIndex={-1} tabIndex={-1}
> >
{/* Header */} {/* Header */}
@@ -82,7 +85,7 @@ export default function StandardModal({ standard, onClose }) {
className="modal-close" className="modal-close"
ref={closeBtnRef} ref={closeBtnRef}
onClick={onClose} onClick={onClose}
aria-label="Close standard detail" aria-label={t("modal.closeLabel")}
> >
</button> </button>
@@ -91,15 +94,15 @@ export default function StandardModal({ standard, onClose }) {
{/* Standard detail */} {/* Standard detail */}
<div className="modal-body"> <div className="modal-body">
{standard.summary && ( {standard.summary && (
<div className="modal-section"> <div className="modal-section" id="modal-summary">
<p className="modal-section-title">Summary</p> <p className="modal-section-title">{t("modal.summary")}</p>
<p className="modal-section-body">{standard.summary}</p> <p className="modal-section-body">{standard.summary}</p>
</div> </div>
)} )}
{standard.keywords?.length > 0 && ( {standard.keywords?.length > 0 && (
<div className="modal-section"> <div className="modal-section">
<p className="modal-section-title">Keywords</p> <p className="modal-section-title">{t("modal.keywords")}</p>
<div className="modal-keywords"> <div className="modal-keywords">
{standard.keywords.map((kw) => ( {standard.keywords.map((kw) => (
<span className="modal-keyword" key={kw}>{kw}</span> <span className="modal-keyword" key={kw}>{kw}</span>
@@ -110,7 +113,7 @@ export default function StandardModal({ standard, onClose }) {
{sections.length > 0 && ( {sections.length > 0 && (
<div className="modal-section"> <div className="modal-section">
<p className="modal-section-title">Key Sections</p> <p className="modal-section-title">{t("modal.keySections")}</p>
<div className="modal-key-sections"> <div className="modal-key-sections">
{sections.map(([name, text]) => ( {sections.map(([name, text]) => (
<div className="key-section-item" key={name}> <div className="key-section-item" key={name}>
@@ -123,24 +126,24 @@ export default function StandardModal({ standard, onClose }) {
)} )}
{/* AI chat panel */} {/* AI chat panel */}
<div className="modal-section ai-panel" aria-label="Ask AI about this standard"> <div className="modal-section ai-panel" aria-label={t("modal.askAI")}>
<p className="modal-section-title"> <p className="modal-section-title">
<span className="ai-label-icon" aria-hidden="true"></span> <span className="ai-label-icon" aria-hidden="true"></span>
Ask AI about this standard {t("modal.askAI")}
</p> </p>
{messages.length > 0 && ( {messages.length > 0 && (
<div className="chat-log" aria-live="polite" aria-label="Conversation"> <div className="chat-log" aria-live="polite" aria-label={t("modal.conversation")}>
{messages.map((m, i) => ( {messages.map((m, i) => (
<div key={i} className={`chat-bubble chat-bubble--${m.role}`}> <div key={i} className={`chat-bubble chat-bubble--${m.role}`}>
{m.role === "ai" && ( {m.role === "ai" && (
<span className="bubble-label" aria-label="AI response"></span> <span className="bubble-label" aria-label={t("modal.aiResponse")}></span>
)} )}
<p className="bubble-text">{m.text}</p> <p className="bubble-text">{m.text}</p>
</div> </div>
))} ))}
{asking && ( {asking && (
<div className="chat-bubble chat-bubble--ai chat-bubble--loading" aria-label="AI is thinking"> <div className="chat-bubble chat-bubble--ai chat-bubble--loading" aria-label={t("modal.aiThinking")}>
<span className="bubble-label" aria-hidden="true"></span> <span className="bubble-label" aria-hidden="true"></span>
<span className="typing-dots"> <span className="typing-dots">
<span /><span /><span /> <span /><span /><span />
@@ -155,8 +158,8 @@ export default function StandardModal({ standard, onClose }) {
)} )}
{messages.length === 0 && !asking && ( {messages.length === 0 && !asking && (
<div className="chat-suggestions" aria-label="Suggested questions"> <div className="chat-suggestions" aria-label={t("modal.suggestionsLabel")}>
{getSuggestions(standard).map((s) => ( {getSuggestions(standard, t).map((s) => (
<button <button
key={s} key={s}
className="suggestion-chip" className="suggestion-chip"
@@ -171,25 +174,25 @@ export default function StandardModal({ standard, onClose }) {
</div> </div>
)} )}
<form className="chat-form" onSubmit={handleAsk} aria-label="Ask a question"> <form className="chat-form" onSubmit={handleAsk} aria-label={t("modal.askAI")}>
<input <input
ref={inputRef} ref={inputRef}
className="chat-input" className="chat-input"
type="text" type="text"
value={question} value={question}
onChange={(e) => setQuestion(e.target.value)} onChange={(e) => setQuestion(e.target.value)}
placeholder="Ask a question about this standard…" placeholder={t("modal.chatPlaceholder")}
maxLength={500} maxLength={500}
disabled={asking} disabled={asking}
aria-label="Your question" aria-label={t("modal.questionLabel")}
/> />
<button <button
className="chat-send" className="chat-send"
type="submit" type="submit"
disabled={!question.trim() || asking} disabled={!question.trim() || asking}
aria-label="Send question" aria-label={t("modal.sendLabel")}
> >
{asking ? "…" : "Ask"} {asking ? t("modal.sending") : t("modal.askBtn")}
</button> </button>
</form> </form>
</div> </div>
@@ -199,17 +202,17 @@ export default function StandardModal({ standard, onClose }) {
); );
} }
function getSuggestions(standard) { function getSuggestions(standard, t) {
const base = [ const base = [
`What are the key requirements of ${standard.standard_id}?`, t("modal.suggestion_keyReq", { id: standard.standard_id }),
"What materials or tests are specified?", t("modal.suggestion_materials"),
"What are the delivery or packaging specifications?", t("modal.suggestion_delivery"),
]; ];
if (standard.key_sections?.["Chemical Requirements"]) { if (standard.key_sections?.["Chemical Requirements"]) {
base.splice(1, 0, "Summarise the chemical requirements."); base.splice(1, 0, t("modal.suggestion_chemical"));
} }
if (standard.key_sections?.["Physical Requirements"] || standard.key_sections?.["Physical Requirement"]) { if (standard.key_sections?.["Physical Requirements"] || standard.key_sections?.["Physical Requirement"]) {
base.splice(1, 0, "What are the physical requirements?"); base.splice(1, 0, t("modal.suggestion_physical"));
} }
return base.slice(0, 3); return base.slice(0, 3);
} }
+25
View File
@@ -0,0 +1,25 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "./locales/en/translation.json";
import hiTranslation from "./locales/hi/translation.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
hi: { translation: hiTranslation },
},
fallbackLng: "en",
supportedLngs: ["en", "hi"],
interpolation: { escapeValue: false }, // React handles XSS
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
},
});
export default i18n;
+25
View File
@@ -1,3 +1,28 @@
/* ── Skip navigation (accessibility) ── */
.skip-nav {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 9999;
}
.skip-nav:focus {
position: fixed;
top: 0;
left: 0;
width: auto;
height: auto;
padding: 0.75rem 1.5rem;
background: var(--accent, #d4530a);
color: #fff;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 4px 0;
outline: 3px solid #fff;
}
/* ── BIS SP-21 Design Tokens ── */ /* ── BIS SP-21 Design Tokens ── */
:root { :root {
--accent: #d4530a; --accent: #d4530a;
+183
View File
@@ -0,0 +1,183 @@
{
"nav": {
"brand": "BIS SP\u201121",
"standards": "Standards",
"categories": "Categories",
"recommend": "\u2746 AI Recommend",
"about": "About",
"bisPortal": "BIS Portal \u2197",
"openMenu": "Open menu",
"closeMenu": "Close menu"
},
"footer": {
"brand": "BIS SP\u201121",
"tagline": "Handbook on Building Materials\nSpecial Publication 21 : 2005",
"portal": "Portal",
"searchStandards": "Search Standards",
"browseCategories": "Browse Categories",
"about": "About",
"bis": "Bureau of Indian Standards",
"bisWebsite": "BIS Official Website",
"manakOnline": "Manak Online",
"standardsPortal": "Standards Portal",
"ministry": "Ministry",
"dpiit": "DPIIT",
"nationalPortal": "National Portal",
"copyright": "\u00a9 Bureau of Indian Standards, Ministry of Commerce & Industry, Government of India. All rights reserved.",
"sourceNote": "Content sourced from BIS Special Publication 21 : 2005. For official standards, refer to"
},
"home": {
"eyebrow": "Special Publication 21 \u00b7 2005",
"heroTitle": "Handbook of\nBuilding Materials",
"heroLead": "Indian Standards across 25 material categories \u2014 searchable, categorised, and ready to reference.",
"searchPlaceholder": "Search standards, e.g. Portland Cement, IS 269\u2026",
"searchLabel": "Search standards",
"searchBtn": "Search",
"statsLabel": "Key statistics",
"statStandards": "IS Standards",
"statCategories": "Categories",
"statPages": "Pages Indexed",
"categoriesHeading": "25 Material Categories",
"categoriesLead": "Every building material section from SP\u201121, indexed and searchable.",
"aboutEyebrow": "About SP\u201121",
"aboutHeading": "India\u2019s Reference for Building Material Standards",
"aboutBody": "BIS Special Publication 21 consolidates all Indian Standards relevant to building and construction materials \u2014 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.",
"visitBIS": "Visit BIS Portal \u2197",
"pillar_instantRetrieval_title": "Instant Retrieval",
"pillar_instantRetrieval_body": "Full-text search across all 573 standards with ranked results.",
"pillar_sectionDetail_title": "Section-Level Detail",
"pillar_sectionDetail_body": "Scope, requirements, delivery conditions \u2014 all structured fields.",
"pillar_categories_title": "25 Categories",
"pillar_categories_body": "Organised by BIS material sections, mirroring SP\u201121\u2019s own structure.",
"pillar_officialSource_title": "Official Source",
"pillar_officialSource_body": "Parsed directly from the BIS SP\u201121 : 2005 authoritative edition.",
"standardCount_one": "{{count}} standard",
"standardCount_other": "{{count}} standards"
},
"standards": {
"heading": "Find an IS Standard",
"lead": "Search by standard number, title, material, or keyword.",
"searchPlaceholder": "e.g. Ordinary Portland Cement, IS 269, aggregates\u2026",
"searchLabel": "Search standards",
"clearSearch": "Clear search",
"allCategories": "All Categories",
"categoryFilter": "Filter by category",
"searching": "Searching\u2026",
"found_one": "{{count}} standard found",
"found_other": "{{count}} standards found",
"page": "page {{page}} of {{total}}",
"noResultsTitle": "No standards found",
"noResultsSub": "Try a different keyword or clear the category filter.",
"pagination": "Results pagination",
"prevPage": "Previous page",
"nextPage": "Next page",
"pageLabel": "Page {{page}}",
"serverError": "Could not load standards. Is the server running?"
},
"categories": {
"eyebrow": "SP\u201121 : 2005",
"heading": "Material Categories",
"lead_one": "{{total}} standards across {{count}} building material section.",
"lead_other": "{{total}} standards across {{count}} building material sections.",
"standardCount": "{{count}} standards",
"allLabel": "All categories"
},
"recommend": {
"eyebrow": "Hybrid Retrieval \u00b7 AI Explanation",
"heading": "Find & Understand Standards",
"lead": "Ask a natural language question \u2014 the system retrieves the most relevant IS standards using dense + sparse search, then explains each in plain English.",
"searchPlaceholder": "e.g. What standard covers tensile strength of structural steel?",
"searchLabel": "Search query",
"clearBtn": "Clear",
"rewriteLabel": "Smart query rewrite",
"rewriteHint": "AI refines your query before searching",
"submitBtn": "Find Standards",
"submitting": "Searching\u2026",
"exampleLabel": "Try an example:",
"loadingRetrieval": "Running hybrid retrieval (FAISS + BM25)\u2026",
"loadingAI": "Generating AI explanations\u2026",
"resultsFound_one": "{{count}} Standard Found",
"resultsFound_other": "{{count}} Standards Found",
"resultsFor": "for:",
"timingLabel": "Timing breakdown",
"retrieval": "Retrieval",
"ai": "AI",
"total": "Total",
"rankLabel": "Rank {{rank}}: {{id}}",
"section": "\u00a7 {{section}}",
"aiExplanation": "AI explanation",
"keywords": "Keywords",
"relevanceScore": "Relevance score {{score}}",
"viewStandard": "View standard details",
"error_prefix": "Error:"
},
"about": {
"eyebrow": "Bureau of Indian Standards",
"heading": "About BIS SP\u201121",
"lead": "India\u2019s authoritative handbook on building and construction material standards.",
"aboutLabel": "About the publication",
"whatTitle": "What is SP\u201121?",
"whatBody1": "BIS Special Publication 21 \u2014 Handbook on Building Materials \u2014 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.",
"whatBody2": "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.",
"whoTitle": "Who uses it?",
"whoBody": "SP\u201121 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.",
"portalTitle": "About this portal",
"portalBody1": "This portal parses the SP\u201121 : 2005 source document into 573 discrete IS standards with structured fields \u2014 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.",
"portalBody2": "The parser uses a two-pass boundary detection algorithm to split the PDF\u2019s continuous text into individual standards, with deduplication, section normalisation, and contamination detection to ensure clean, reliable data.",
"sidebarPubDetails": "Publication Details",
"publisher": "Publisher",
"publisherValue": "Bureau of Indian Standards",
"edition": "Edition",
"editionValue": "SP 21 : 2005",
"pages": "Pages",
"pagesValue": "929",
"standardsIndexed": "Standards indexed",
"standardsIndexedValue": "573",
"categoriesLabel": "Categories",
"categoriesValue": "25",
"ministry": "Ministry",
"ministryValue": "DPIIT, Govt. of India",
"officialLinks": "Official Links",
"bisWebsite": "BIS Official Website",
"manakOnline": "Manak Online",
"standardsPortal": "Standards Portal",
"dpiit": "DPIIT"
},
"modal": {
"closeLabel": "Close standard detail",
"summary": "Summary",
"keywords": "Keywords",
"keySections": "Key Sections",
"askAI": "Ask AI about this standard",
"conversation": "Conversation",
"aiResponse": "AI response",
"aiThinking": "AI is thinking",
"suggestionsLabel": "Suggested questions",
"chatPlaceholder": "Ask a question about this standard\u2026",
"questionLabel": "Your question",
"sendLabel": "Send question",
"sending": "\u2026",
"askBtn": "Ask",
"notFound": "No content found for this standard.",
"suggestion_keyReq": "What are the key requirements of {{id}}?",
"suggestion_materials": "What materials or tests are specified?",
"suggestion_delivery": "What are the delivery or packaging specifications?",
"suggestion_chemical": "Summarise the chemical requirements.",
"suggestion_physical": "What are the physical requirements?"
},
"card": {
"viewDetails": "View details for {{id}}",
"section_one": "{{count}} section",
"section_other": "{{count}} sections",
"keywords": "Keywords"
},
"common": {
"serverError": "Could not connect to the server.",
"loading": "Loading\u2026"
},
"lang": {
"en": "English",
"hi": "\u0939\u093f\u0928\u094d\u0926\u0940",
"switchTo": "Switch language"
}
}
+183
View File
@@ -0,0 +1,183 @@
{
"nav": {
"brand": "BIS SP\u201121",
"standards": "\u092e\u093e\u0928\u0915",
"categories": "\u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"recommend": "\u2746 AI \u0905\u0928\u0941\u0936\u0902\u0938\u093e",
"about": "\u0939\u092e\u093e\u0930\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902",
"bisPortal": "BIS \u092a\u094b\u0930\u094d\u091f\u0932 \u2197",
"openMenu": "\u092e\u0947\u0928\u0942 \u0916\u094b\u0932\u0947\u0902",
"closeMenu": "\u092e\u0947\u0928\u0942 \u092c\u0902\u0926 \u0915\u0930\u0947\u0902"
},
"footer": {
"brand": "BIS SP\u201121",
"tagline": "\u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u092a\u0941\u0938\u094d\u0924\u093f\u0915\u093e\nSP 21 : 2005",
"portal": "\u092a\u094b\u0930\u094d\u091f\u0932",
"searchStandards": "\u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902",
"browseCategories": "\u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901 \u0926\u0947\u0916\u0947\u0902",
"about": "\u0939\u092e\u093e\u0930\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902",
"bis": "\u092d\u093e\u0930\u0924\u0940\u092f \u092e\u093e\u0928\u0915 \u092c\u094d\u092f\u0942\u0930\u094b",
"bisWebsite": "BIS \u0906\u0927\u093f\u0915\u093e\u0930\u093f\u0915 \u0935\u0947\u092c\u0938\u093e\u0907\u091f",
"manakOnline": "\u092e\u093e\u0928\u0915 \u0911\u0928\u0932\u093e\u0907\u0928",
"standardsPortal": "\u092e\u093e\u0928\u0915 \u092a\u094b\u0930\u094d\u091f\u0932",
"ministry": "\u092e\u0902\u0924\u094d\u0930\u093e\u0932\u092f",
"dpiit": "DPIIT",
"nationalPortal": "\u0930\u093e\u0937\u094d\u091f\u094d\u0930\u0940\u092f \u092a\u094b\u0930\u094d\u091f\u0932",
"copyright": "\u00a9 \u092d\u093e\u0930\u0924\u0940\u092f \u092e\u093e\u0928\u0915 \u092c\u094d\u092f\u0942\u0930\u094b, \u0935\u093e\u0923\u093f\u091c\u094d\u092f \u090f\u0935\u0902 \u0909\u0926\u094d\u092f\u094b\u0917 \u092e\u0902\u0924\u094d\u0930\u093e\u0932\u092f, \u092d\u093e\u0930\u0924 \u0938\u0930\u0915\u093e\u0930\u0964 \u0938\u0930\u094d\u0935\u093e\u0927\u093f\u0915\u093e\u0930 \u0938\u0941\u0930\u0915\u094d\u0937\u093f\u0924\u0964",
"sourceNote": "BIS SP 21 : 2005 \u0938\u0947 \u0938\u0902\u0915\u0932\u093f\u0924\u0964 \u0906\u0927\u093f\u0915\u093e\u0930\u093f\u0915 \u092e\u093e\u0928\u0915\u094b\u0902 \u0915\u0947 \u0932\u093f\u090f \u0926\u0947\u0916\u0947\u0902"
},
"home": {
"eyebrow": "\u0935\u093f\u0936\u0947\u0937 \u092a\u094d\u0930\u0915\u093e\u0936\u0928 21 \u00b7 2005",
"heroTitle": "\u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940\u0915\u093e \u0939\u0938\u094d\u0924\u092a\u0941\u0938\u094d\u0924\u093f\u0915\u093e",
"heroLead": "25 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0936\u094d\u0930\u0947\u0923\u093f\u092f\u094b\u0902 \u092e\u0947\u0902 \u092d\u093e\u0930\u0924\u0940\u092f \u092e\u093e\u0928\u0915 \u2014 \u0916\u094b\u091c\u092f\u094b\u0917\u094d\u092f, \u0935\u0930\u094d\u0917\u0940\u0915\u0943\u0924 \u0914\u0930 \u0938\u0902\u0926\u0930\u094d\u092d \u0915\u0947 \u0932\u093f\u090f \u0924\u0948\u092f\u093e\u0930\u0964",
"searchPlaceholder": "\u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902, \u091c\u0948\u0938\u0947 Portland Cement, IS 269\u2026",
"searchLabel": "\u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902",
"searchBtn": "\u0916\u094b\u091c\u0947\u0902",
"statsLabel": "\u092e\u0941\u0916\u094d\u092f \u0906\u0902\u0915\u095c\u0947",
"statStandards": "IS \u092e\u093e\u0928\u0915",
"statCategories": "\u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"statPages": "\u0938\u0942\u091a\u0940\u092c\u0926\u094d\u0927 \u092a\u0943\u0937\u094d\u0920",
"categoriesHeading": "25 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"categoriesLead": "SP\u201121 \u0915\u0940 \u0939\u0930 \u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0936\u094d\u0930\u0947\u0923\u0940, \u0938\u0942\u091a\u0940\u092c\u0926\u094d\u0927 \u0914\u0930 \u0916\u094b\u091c\u092f\u094b\u0917\u094d\u092f\u0964",
"aboutEyebrow": "SP\u201121 \u0915\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902",
"aboutHeading": "\u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u092e\u093e\u0928\u0915\u094b\u0902 \u0915\u093e \u092d\u093e\u0930\u0924\u0940\u092f \u0938\u0902\u0926\u0930\u094d\u092d",
"aboutBody": "BIS \u0935\u093f\u0936\u0947\u0937 \u092a\u094d\u0930\u0915\u093e\u0936\u0928 21 \u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0914\u0930 \u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u093f\u092f\u094b\u0902 \u0938\u0947 \u0938\u0902\u092c\u0902\u0927\u093f\u0924 \u0938\u092d\u0940 IS \u092e\u093e\u0928\u0915\u094b\u0902 \u0915\u094b \u090f\u0915\u0924\u094d\u0930 \u0915\u0930\u0924\u093e \u0939\u0948\u0964",
"visitBIS": "BIS \u092a\u094b\u0930\u094d\u091f\u0932 \u0926\u0947\u0916\u0947\u0902 \u2197",
"pillar_instantRetrieval_title": "\u0924\u0924\u094d\u0915\u093e\u0932 \u0916\u094b\u091c",
"pillar_instantRetrieval_body": "\u0938\u092d\u0940 573 \u092e\u093e\u0928\u0915\u094b\u0902 \u092e\u0947\u0902 \u092a\u0942\u0930\u094d\u0923-\u092a\u093e\u0920 \u0916\u094b\u091c \u0930\u0948\u0902\u0915\u093f\u0902\u0917 \u092a\u0930\u093f\u0923\u093e\u092e\u094b\u0902 \u0915\u0947 \u0938\u093e\u0925\u0964",
"pillar_sectionDetail_title": "\u0927\u093e\u0930\u093e-\u0938\u094d\u0924\u0930\u0940\u092f \u0935\u093f\u0935\u0930\u0923",
"pillar_sectionDetail_body": "\u0915\u094d\u0937\u0947\u0924\u094d\u0930, \u0906\u0935\u0936\u094d\u092f\u0915\u0924\u093e\u090f\u0901, \u0921\u093f\u0932\u0940\u0935\u0930\u0940 \u0936\u0930\u094d\u0924\u0947\u0902 \u2014 \u0938\u092d\u0940 \u0938\u0902\u0930\u091a\u093f\u0924 \u092b\u093c\u0940\u0932\u094d\u0921\u0964",
"pillar_categories_title": "25 \u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"pillar_categories_body": "BIS \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0916\u0902\u0921\u094b\u0902 \u0926\u094d\u0935\u093e\u0930\u093e \u0935\u094d\u092f\u0935\u0938\u094d\u0925\u093f\u0924, SP\u201121 \u0915\u0940 \u0905\u092a\u0928\u0940 \u0938\u0902\u0930\u091a\u0928\u093e \u0915\u093e \u0905\u0928\u0941\u0938\u0930\u0923\u0964",
"pillar_officialSource_title": "\u0906\u0927\u093f\u0915\u093e\u0930\u093f\u0915 \u0938\u094d\u0930\u094b\u0924",
"pillar_officialSource_body": "BIS SP\u201121 : 2005 \u0938\u0947 \u0938\u0940\u0927\u0947 \u092a\u093e\u0930\u094d\u0938 \u0915\u093f\u092f\u093e \u0917\u092f\u093e\u0964",
"standardCount_one": "{{count}} \u092e\u093e\u0928\u0915",
"standardCount_other": "{{count}} \u092e\u093e\u0928\u0915"
},
"standards": {
"heading": "IS \u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902",
"lead": "\u092e\u093e\u0928\u0915 \u0938\u0902\u0916\u094d\u092f\u093e, \u0936\u0940\u0930\u094d\u0937\u0915, \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u092f\u093e \u0915\u0940\u0935\u0930\u094d\u0921 \u0926\u094d\u0935\u093e\u0930\u093e \u0916\u094b\u091c\u0947\u0902\u0964",
"searchPlaceholder": "\u091c\u0948\u0938\u0947 Ordinary Portland Cement, IS 269\u2026",
"searchLabel": "\u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902",
"clearSearch": "\u0916\u094b\u091c \u0938\u093e\u092b\u093c \u0915\u0930\u0947\u0902",
"allCategories": "\u0938\u092d\u0940 \u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"categoryFilter": "\u0936\u094d\u0930\u0947\u0923\u0940 \u0938\u0947 \u092b\u093c\u093f\u0932\u094d\u091f\u0930 \u0915\u0930\u0947\u0902",
"searching": "\u0916\u094b\u091c \u091c\u093e\u0930\u0940\u2026",
"found_one": "{{count}} \u092e\u093e\u0928\u0915 \u092e\u093f\u0932\u093e",
"found_other": "{{count}} \u092e\u093e\u0928\u0915 \u092e\u093f\u0932\u0947",
"page": "\u092a\u0943\u0937\u094d\u0920 {{page}} / {{total}}",
"noResultsTitle": "\u0915\u094b\u0908 \u092e\u093e\u0928\u0915 \u0928\u0939\u0940\u0902 \u092e\u093f\u0932\u093e",
"noResultsSub": "\u0905\u0932\u0917 \u0915\u0940\u0935\u0930\u094d\u0921 \u0906\u091c\u093c\u092e\u093e\u090f\u0901 \u092f\u093e \u0936\u094d\u0930\u0947\u0923\u0940 \u092b\u093c\u093f\u0932\u094d\u091f\u0930 \u0939\u091f\u093e\u090f\u0902\u0964",
"pagination": "\u092a\u0930\u093f\u0923\u093e\u092e \u092a\u0943\u0937\u094d\u0920\u093e\u0902\u0915\u0928",
"prevPage": "\u092a\u093f\u091b\u0932\u093e \u092a\u0943\u0937\u094d\u0920",
"nextPage": "\u0905\u0917\u0932\u093e \u092a\u0943\u0937\u094d\u0920",
"pageLabel": "\u092a\u0943\u0937\u094d\u0920 {{page}}",
"serverError": "\u092e\u093e\u0928\u0915 \u0932\u094b\u0921 \u0928\u0939\u0940\u0902 \u0939\u094b \u0938\u0915\u0947\u0964 \u0915\u094d\u092f\u093e \u0938\u0930\u094d\u0935\u0930 \u091a\u0932 \u0930\u0939\u093e \u0939\u0948?"
},
"categories": {
"eyebrow": "SP\u201121 : 2005",
"heading": "\u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"lead_one": "{{count}} \u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0916\u0902\u0921 \u092e\u0947\u0902 {{total}} \u092e\u093e\u0928\u0915\u0964",
"lead_other": "{{count}} \u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0916\u0902\u0921\u094b\u0902 \u092e\u0947\u0902 {{total}} \u092e\u093e\u0928\u0915\u0964",
"standardCount": "{{count}} \u092e\u093e\u0928\u0915",
"allLabel": "\u0938\u092d\u0940 \u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901"
},
"recommend": {
"eyebrow": "\u0939\u093e\u0907\u092c\u094d\u0930\u093f\u0921 \u092a\u0941\u0928\u0930\u094d\u092a\u094d\u0930\u093e\u092a\u094d\u0924\u093f \u00b7 AI \u0935\u094d\u092f\u093e\u0916\u094d\u092f\u093e",
"heading": "\u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902 \u0914\u0930 \u0938\u092e\u091d\u0947\u0902",
"lead": "\u092a\u094d\u0930\u093e\u0915\u0943\u0924\u093f\u0915 \u092d\u093e\u0937\u093e \u092e\u0947\u0902 \u092a\u0942\u091b\u0947\u0902 \u2014 \u0938\u093f\u0938\u094d\u091f\u092e dense + sparse \u0916\u094b\u091c \u0938\u0947 \u0938\u092c\u0938\u0947 \u092a\u094d\u0930\u093e\u0938\u0902\u0917\u093f\u0915 IS \u092e\u093e\u0928\u0915 \u0922\u0942\u0902\u0922\u0924\u093e \u0939\u0948\u0964",
"searchPlaceholder": "\u091c\u0948\u0938\u0947: \u0938\u0902\u0930\u091a\u0928\u093e\u0924\u094d\u092e\u0915 \u0938\u094d\u091f\u0940\u0932 \u0915\u0940 \u0924\u0928\u094d\u092f \u0936\u0915\u094d\u0924\u093f \u0915\u093e \u092e\u093e\u0928\u0915 \u0915\u094c\u0928 \u0938\u093e \u0939\u0948?",
"searchLabel": "\u0916\u094b\u091c \u092a\u094d\u0930\u0936\u094d\u0928",
"clearBtn": "\u0938\u093e\u092b\u093c \u0915\u0930\u0947\u0902",
"rewriteLabel": "\u0938\u094d\u092e\u093e\u0930\u094d\u091f \u0915\u094d\u0935\u0947\u0930\u0940 \u0930\u093f\u0930\u093e\u0907\u091f",
"rewriteHint": "AI \u0916\u094b\u091c \u0938\u0947 \u092a\u0939\u0932\u0947 \u0906\u092a\u0915\u0940 \u0915\u094d\u0935\u0947\u0930\u0940 \u0938\u0941\u0927\u093e\u0930\u0924\u093e \u0939\u0948",
"submitBtn": "\u092e\u093e\u0928\u0915 \u0916\u094b\u091c\u0947\u0902",
"submitting": "\u0916\u094b\u091c \u091c\u093e\u0930\u0940\u2026",
"exampleLabel": "\u0909\u0926\u093e\u0939\u0930\u0923 \u0906\u091c\u093c\u092e\u093e\u090f\u0901:",
"loadingRetrieval": "\u0939\u093e\u0907\u092c\u094d\u0930\u093f\u0921 \u0916\u094b\u091c \u091a\u0932 \u0930\u0939\u0940 \u0939\u0948 (FAISS + BM25)\u2026",
"loadingAI": "AI \u0935\u094d\u092f\u093e\u0916\u094d\u092f\u093e \u0924\u0948\u092f\u093e\u0930 \u0939\u094b \u0930\u0939\u0940 \u0939\u0948\u2026",
"resultsFound_one": "{{count}} \u092e\u093e\u0928\u0915 \u092e\u093f\u0932\u093e",
"resultsFound_other": "{{count}} \u092e\u093e\u0928\u0915 \u092e\u093f\u0932\u0947",
"resultsFor": "\u0916\u094b\u091c:",
"timingLabel": "\u0938\u092e\u092f \u0935\u093f\u0935\u0930\u0923",
"retrieval": "\u0916\u094b\u091c",
"ai": "AI",
"total": "\u0915\u0941\u0932",
"rankLabel": "\u0930\u0948\u0902\u0915 {{rank}}: {{id}}",
"section": "\u00a7 {{section}}",
"aiExplanation": "AI \u0935\u094d\u092f\u093e\u0916\u094d\u092f\u093e",
"keywords": "\u0915\u0940\u0935\u0930\u094d\u0921",
"relevanceScore": "\u092a\u094d\u0930\u093e\u0938\u0902\u0917\u093f\u0915\u0924\u093e \u0938\u094d\u0915\u094b\u0930 {{score}}",
"viewStandard": "\u092e\u093e\u0928\u0915 \u0935\u093f\u0935\u0930\u0923 \u0926\u0947\u0916\u0947\u0902",
"error_prefix": "\u0924\u094d\u0930\u0941\u091f\u093f:"
},
"about": {
"eyebrow": "\u092d\u093e\u0930\u0924\u0940\u092f \u092e\u093e\u0928\u0915 \u092c\u094d\u092f\u0942\u0930\u094b",
"heading": "BIS SP\u201121 \u0915\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902",
"lead": "\u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u092e\u093e\u0928\u0915\u094b\u0902 \u092a\u0930 \u092d\u093e\u0930\u0924 \u0915\u0940 \u0906\u0927\u093f\u0915\u093e\u0930\u093f\u0915 \u092a\u0941\u0938\u094d\u0924\u093f\u0915\u093e\u0964",
"aboutLabel": "\u092a\u094d\u0930\u0915\u093e\u0936\u0928 \u0915\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902",
"whatTitle": "SP\u201121 \u0915\u094d\u092f\u093e \u0939\u0948?",
"whatBody1": "BIS \u0935\u093f\u0936\u0947\u0937 \u092a\u094d\u0930\u0915\u093e\u0936\u0928 21 \u2014 \u0928\u093f\u0930\u094d\u092e\u093e\u0923 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u092a\u0941\u0938\u094d\u0924\u093f\u0915\u093e \u2014 \u092d\u093e\u0930\u0924\u0940\u092f \u092e\u093e\u0928\u0915 \u092c\u094d\u092f\u0942\u0930\u094b \u0926\u094d\u0935\u093e\u0930\u093e \u092a\u094d\u0930\u0915\u093e\u0936\u093f\u0924 \u090f\u0915 \u0938\u0902\u092f\u0941\u0915\u094d\u0924 \u0938\u0902\u0926\u0930\u094d\u092d \u0939\u0948\u0964",
"whatBody2": "2005 \u0938\u0902\u0938\u094d\u0915\u0930\u0923 25 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0916\u0902\u0921\u094b\u0902 \u092e\u0947\u0902 929 \u092a\u0943\u0937\u094d\u0920 \u0915\u0935\u0930 \u0915\u0930\u0924\u093e \u0939\u0948\u0964",
"whoTitle": "\u0907\u0938\u0947 \u0915\u094c\u0928 \u0909\u092a\u092f\u094b\u0917 \u0915\u0930\u0924\u093e \u0939\u0948?",
"whoBody": "SP\u201121 \u0915\u093e \u0909\u092a\u092f\u094b\u0917 \u0938\u0902\u0930\u091a\u0928\u093e\u0924\u094d\u092e\u0915 \u0907\u0902\u091c\u0940\u0928\u093f\u092f\u0930, \u0935\u093e\u0938\u094d\u0924\u0941\u0915\u093e\u0930, \u0920\u0947\u0915\u0947\u0926\u093e\u0930, \u0917\u0941\u0923\u0935\u0924\u094d\u0924\u093e \u0928\u093f\u0930\u0940\u0915\u094d\u0937\u0915 \u0914\u0930 \u0916\u0930\u0940\u0926 \u0905\u0927\u093f\u0915\u093e\u0930\u0940 \u0915\u0930\u0924\u0947 \u0939\u0948\u0902\u0964",
"portalTitle": "\u0907\u0938 \u092a\u094b\u0930\u094d\u091f\u0932 \u0915\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902",
"portalBody1": "\u092f\u0939 \u092a\u094b\u0930\u094d\u091f\u0932 SP\u201121 : 2005 \u0915\u094b 573 IS \u092e\u093e\u0928\u0915\u094b\u0902 \u092e\u0947\u0902 \u092a\u093e\u0930\u094d\u0938 \u0915\u0930\u0924\u093e \u0939\u0948\u0964",
"portalBody2": "\u092a\u093e\u0930\u094d\u0938\u0930 \u0926\u094b-\u092a\u093e\u0938 \u0938\u0940\u092e\u093e \u0928\u093f\u0930\u094d\u0927\u093e\u0930\u0923 \u090f\u0932\u094d\u0917\u094b\u0930\u093f\u0926\u092e \u0915\u093e \u0909\u092a\u092f\u094b\u0917 \u0915\u0930\u0924\u093e \u0939\u0948\u0964",
"sidebarPubDetails": "\u092a\u094d\u0930\u0915\u093e\u0936\u0928 \u0935\u093f\u0935\u0930\u0923",
"publisher": "\u092a\u094d\u0930\u0915\u093e\u0936\u0915",
"publisherValue": "\u092d\u093e\u0930\u0924\u0940\u092f \u092e\u093e\u0928\u0915 \u092c\u094d\u092f\u0942\u0930\u094b",
"edition": "\u0938\u0902\u0938\u094d\u0915\u0930\u0923",
"editionValue": "SP 21 : 2005",
"pages": "\u092a\u0943\u0937\u094d\u0920",
"pagesValue": "929",
"standardsIndexed": "\u0938\u0942\u091a\u0940\u092c\u0926\u094d\u0927 \u092e\u093e\u0928\u0915",
"standardsIndexedValue": "573",
"categoriesLabel": "\u0936\u094d\u0930\u0947\u0923\u093f\u092f\u093e\u0901",
"categoriesValue": "25",
"ministry": "\u092e\u0902\u0924\u094d\u0930\u093e\u0932\u092f",
"ministryValue": "DPIIT, \u092d\u093e\u0930\u0924 \u0938\u0930\u0915\u093e\u0930",
"officialLinks": "\u0906\u0927\u093f\u0915\u093e\u0930\u093f\u0915 \u0932\u093f\u0902\u0915",
"bisWebsite": "BIS \u0906\u0927\u093f\u0915\u093e\u0930\u093f\u0915 \u0935\u0947\u092c\u0938\u093e\u0907\u091f",
"manakOnline": "\u092e\u093e\u0928\u0915 \u0911\u0928\u0932\u093e\u0907\u0928",
"standardsPortal": "\u092e\u093e\u0928\u0915 \u092a\u094b\u0930\u094d\u091f\u0932",
"dpiit": "DPIIT"
},
"modal": {
"closeLabel": "\u092e\u093e\u0928\u0915 \u0935\u093f\u0935\u0930\u0923 \u092c\u0902\u0926 \u0915\u0930\u0947\u0902",
"summary": "\u0938\u093e\u0930\u093e\u0902\u0936",
"keywords": "\u0915\u0940\u0935\u0930\u094d\u0921",
"keySections": "\u092e\u0941\u0916\u094d\u092f \u0927\u093e\u0930\u093e\u090f\u0901",
"askAI": "\u0907\u0938 \u092e\u093e\u0928\u0915 \u0915\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902 AI \u0938\u0947 \u092a\u0942\u091b\u0947\u0902",
"conversation": "\u0935\u093e\u0930\u094d\u0924\u093e\u0932\u093e\u092a",
"aiResponse": "AI \u0909\u0924\u094d\u0924\u0930",
"aiThinking": "AI \u0938\u094b\u091a \u0930\u0939\u093e \u0939\u0948",
"suggestionsLabel": "\u0938\u0941\u091d\u093e\u090f \u0917\u090f \u092a\u094d\u0930\u0936\u094d\u0928",
"chatPlaceholder": "\u0907\u0938 \u092e\u093e\u0928\u0915 \u0915\u0947 \u092c\u093e\u0930\u0947 \u092e\u0947\u0902 \u092a\u094d\u0930\u0936\u094d\u0928 \u092a\u0942\u091b\u0947\u0902\u2026",
"questionLabel": "\u0906\u092a\u0915\u093e \u092a\u094d\u0930\u0936\u094d\u0928",
"sendLabel": "\u092a\u094d\u0930\u0936\u094d\u0928 \u092d\u0947\u091c\u0947\u0902",
"sending": "\u2026",
"askBtn": "\u092a\u0942\u091b\u0947\u0902",
"notFound": "\u0907\u0938 \u092e\u093e\u0928\u0915 \u092e\u0947\u0902 \u0938\u093e\u092e\u0917\u094d\u0930\u0940 \u0928\u0939\u0940\u0902 \u092e\u093f\u0932\u0940\u0964",
"suggestion_keyReq": "{{id}} \u0915\u0940 \u092e\u0941\u0916\u094d\u092f \u0906\u0935\u0936\u094d\u092f\u0915\u0924\u093e\u090f\u0901 \u0915\u094d\u092f\u093e \u0939\u0948\u0902?",
"suggestion_materials": "\u0915\u094c\u0928 \u0938\u0940 \u0938\u093e\u092e\u0917\u094d\u0930\u093f\u092f\u093e\u0901 \u092f\u093e \u092a\u0930\u0940\u0915\u094d\u0937\u0923 \u0928\u093f\u0930\u094d\u0926\u093f\u0937\u094d\u091f \u0939\u0948\u0902?",
"suggestion_delivery": "\u0921\u093f\u0932\u0940\u0935\u0930\u0940 \u092f\u093e \u092a\u0948\u0915\u0947\u091c\u093f\u0902\u0917 \u0935\u093f\u0936\u093f\u0937\u094d\u091f\u0924\u093e\u090f\u0901 \u0915\u094d\u092f\u093e \u0939\u0948\u0902?",
"suggestion_chemical": "\u0930\u093e\u0938\u093e\u092f\u0928\u093f\u0915 \u0906\u0935\u0936\u094d\u092f\u0915\u0924\u093e\u0913\u0902 \u0915\u093e \u0938\u093e\u0930\u093e\u0902\u0936 \u0926\u0947\u0902\u0964",
"suggestion_physical": "\u092d\u094c\u0924\u093f\u0915 \u0906\u0935\u0936\u094d\u092f\u0915\u0924\u093e\u090f\u0901 \u0915\u094d\u092f\u093a \u0939\u0948\u0902?"
},
"card": {
"viewDetails": "{{id}} \u0915\u093e \u0935\u093f\u0935\u0930\u0923 \u0926\u0947\u0916\u0947\u0902",
"section_one": "{{count}} \u0927\u093e\u0930\u093e",
"section_other": "{{count}} \u0927\u093e\u0930\u093e\u090f\u0901",
"keywords": "\u0915\u0940\u0935\u0930\u094d\u0921"
},
"common": {
"serverError": "\u0938\u0930\u094d\u0935\u0930 \u0938\u0947 \u0915\u0928\u0947\u0915\u094d\u091f \u0928\u0939\u0940\u0902 \u0939\u094b \u0938\u0915\u093e\u0964",
"loading": "\u0932\u094b\u0921 \u0939\u094b \u0930\u0939\u093e \u0939\u0948\u2026"
},
"lang": {
"en": "English",
"hi": "\u0939\u093f\u0928\u094d\u0926\u0940",
"switchTo": "\u092d\u093e\u0937\u093e \u092c\u0926\u0932\u0947\u0902"
}
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "./i18n.js";
import "./index.css"; import "./index.css";
import App from "./App.jsx"; import App from "./App.jsx";
+27 -48
View File
@@ -1,80 +1,59 @@
import { useTranslation } from "react-i18next";
import "./About.css"; import "./About.css";
export default function About() { export default function About() {
const { t } = useTranslation();
return ( return (
<main> <main>
<section className="tile tile-dark about-hero" aria-labelledby="about-heading"> <section className="tile tile-dark about-hero" aria-labelledby="about-heading">
<div className="tile-inner tile-center"> <div className="tile-inner tile-center">
<p className="tile-eyebrow">Bureau of Indian Standards</p> <p className="tile-eyebrow">{t("about.eyebrow")}</p>
<h1 className="hero-display" id="about-heading">About BIS SP21</h1> <h1 className="hero-display" id="about-heading">{t("about.heading")}</h1>
<p className="lead"> <p className="lead">{t("about.lead")}</p>
India's authoritative handbook on building and construction material standards.
</p>
</div> </div>
</section> </section>
<section className="tile tile-light" aria-label="About the publication"> <section className="tile tile-light" aria-label={t("about.aboutLabel")}>
<div className="tile-inner about-content"> <div className="tile-inner about-content">
<div className="about-main"> <div className="about-main">
<h2 className="about-section-title">What is SP21?</h2> <h2 className="about-section-title">{t("about.whatTitle")}</h2>
<p className="about-body"> <p className="about-body">{t("about.whatBody1")}</p>
BIS Special Publication 21 — <em>Handbook on Building Materials</em> — is a consolidated <p className="about-body">{t("about.whatBody2")}</p>
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> <h2 className="about-section-title">{t("about.whoTitle")}</h2>
<p className="about-body"> <p className="about-body">{t("about.whoBody")}</p>
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> <h2 className="about-section-title">{t("about.portalTitle")}</h2>
<p className="about-body"> <p className="about-body">{t("about.portalBody1")}</p>
This portal parses the SP21 : 2005 source document into 573 discrete IS standards with <p className="about-body">{t("about.portalBody2")}</p>
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> </div>
<aside className="about-sidebar"> <aside className="about-sidebar">
<div className="about-stat-card"> <div className="about-stat-card">
<h3 className="sidebar-heading">Publication Details</h3> <h3 className="sidebar-heading">{t("about.sidebarPubDetails")}</h3>
<dl className="detail-list"> <dl className="detail-list">
<dt>Publisher</dt><dd>Bureau of Indian Standards</dd> <dt>{t("about.publisher")}</dt><dd>{t("about.publisherValue")}</dd>
<dt>Edition</dt><dd>SP 21 : 2005</dd> <dt>{t("about.edition")}</dt><dd>{t("about.editionValue")}</dd>
<dt>Pages</dt><dd>929</dd> <dt>{t("about.pages")}</dt><dd>{t("about.pagesValue")}</dd>
<dt>Standards indexed</dt><dd>573</dd> <dt>{t("about.standardsIndexed")}</dt><dd>{t("about.standardsIndexedValue")}</dd>
<dt>Categories</dt><dd>25</dd> <dt>{t("about.categoriesLabel")}</dt><dd>{t("about.categoriesValue")}</dd>
<dt>Ministry</dt><dd>DPIIT, Govt. of India</dd> <dt>{t("about.ministry")}</dt><dd>{t("about.ministryValue")}</dd>
</dl> </dl>
</div> </div>
<div className="about-links-card"> <div className="about-links-card">
<h3 className="sidebar-heading">Official Links</h3> <h3 className="sidebar-heading">{t("about.officialLinks")}</h3>
<a className="ext-link" href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer"> <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> <span>{t("about.bisWebsite")}</span><span aria-hidden="true"></span>
</a> </a>
<a className="ext-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer"> <a className="ext-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">
<span>Manak Online</span><span aria-hidden="true"></span> <span>{t("about.manakOnline")}</span><span aria-hidden="true"></span>
</a> </a>
<a className="ext-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer"> <a className="ext-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">
<span>Standards Portal</span><span aria-hidden="true"></span> <span>{t("about.standardsPortal")}</span><span aria-hidden="true"></span>
</a> </a>
<a className="ext-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer"> <a className="ext-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">
<span>DPIIT</span><span aria-hidden="true"></span> <span>{t("about.dpiit")}</span><span aria-hidden="true"></span>
</a> </a>
</div> </div>
</aside> </aside>
+7 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { fetchCategories } from "../api/standards"; import { fetchCategories } from "../api/standards";
import "./Categories.css"; import "./Categories.css";
@@ -32,6 +33,7 @@ const CATEGORY_ICONS = {
}; };
export default function Categories() { export default function Categories() {
const { t } = useTranslation();
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -49,15 +51,15 @@ export default function Categories() {
<main> <main>
<section className="tile tile-dark cat-hero" aria-labelledby="cat-page-heading"> <section className="tile tile-dark cat-hero" aria-labelledby="cat-page-heading">
<div className="tile-inner tile-center"> <div className="tile-inner tile-center">
<p className="tile-eyebrow">SP21 : 2005</p> <p className="tile-eyebrow">{t("categories.eyebrow")}</p>
<h1 className="hero-display" id="cat-page-heading">Material Categories</h1> <h1 className="hero-display" id="cat-page-heading">{t("categories.heading")}</h1>
<p className="lead"> <p className="lead">
{total} standards across {categories.length} building material sections. {t("categories.lead", { count: categories.length, total })}
</p> </p>
</div> </div>
</section> </section>
<section className="tile tile-light" aria-label="All categories"> <section className="tile tile-light" aria-label={t("categories.allLabel")}>
<div className="tile-inner"> <div className="tile-inner">
{loading ? ( {loading ? (
<div className="cat-skeleton"> <div className="cat-skeleton">
@@ -78,7 +80,7 @@ export default function Categories() {
{CATEGORY_ICONS[cat.name] || "📋"} {CATEGORY_ICONS[cat.name] || "📋"}
</span> </span>
<span className="cat-page-name">{cat.name}</span> <span className="cat-page-name">{cat.name}</span>
<span className="cat-page-count">{cat.count} standards</span> <span className="cat-page-count">{t("categories.standardCount", { count: cat.count })}</span>
<span className="cat-page-arrow" aria-hidden="true"></span> <span className="cat-page-arrow" aria-hidden="true"></span>
</button> </button>
))} ))}
+33 -42
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { fetchStats, fetchCategories } from "../api/standards"; import { fetchStats, fetchCategories } from "../api/standards";
import "./Home.css"; import "./Home.css";
export default function Home() { export default function Home() {
const { t } = useTranslation();
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -20,19 +22,24 @@ export default function Home() {
else navigate("/standards"); else navigate("/standards");
}; };
const PILLARS = [
{ icon: "⚡", titleKey: "home.pillar_instantRetrieval_title", bodyKey: "home.pillar_instantRetrieval_body" },
{ icon: "📐", titleKey: "home.pillar_sectionDetail_title", bodyKey: "home.pillar_sectionDetail_body" },
{ icon: "🗂", titleKey: "home.pillar_categories_title", bodyKey: "home.pillar_categories_body" },
{ icon: "🔒", titleKey: "home.pillar_officialSource_title", bodyKey: "home.pillar_officialSource_body" },
];
return ( return (
<main> <main>
{/* Hero */}
<section className="tile tile-dark hero-tile" aria-labelledby="hero-heading"> <section className="tile tile-dark hero-tile" aria-labelledby="hero-heading">
<div className="tile-inner tile-center"> <div className="tile-inner tile-center">
<p className="tile-eyebrow">Special Publication 21 · 2005</p> <p className="tile-eyebrow">{t("home.eyebrow")}</p>
<h1 className="hero-display" id="hero-heading"> <h1 className="hero-display" id="hero-heading">
Handbook of<br />Building Materials {t("home.heroTitle").split("\n").map((line, i, arr) => (
<span key={i}>{line}{i < arr.length - 1 && <br />}</span>
))}
</h1> </h1>
<p className="lead"> <p className="lead">{t("home.heroLead")}</p>
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"> <form className="hero-search-form" onSubmit={handleSearch} role="search">
<div className="hero-search-wrap"> <div className="hero-search-wrap">
@@ -42,40 +49,39 @@ export default function Home() {
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search standards, e.g. Portland Cement, IS 269…" placeholder={t("home.searchPlaceholder")}
aria-label="Search standards" aria-label={t("home.searchLabel")}
/> />
<button className="btn-primary hero-search-btn" type="submit">Search</button> <button className="btn-primary hero-search-btn" type="submit">{t("home.searchBtn")}</button>
</div> </div>
</form> </form>
{stats && ( {stats && (
<div className="hero-stats" aria-label="Key statistics"> <div className="hero-stats" aria-label={t("home.statsLabel")}>
<div className="stat"> <div className="stat">
<span className="stat-num">{stats.totalStandards}</span> <span className="stat-num">{stats.totalStandards}</span>
<span className="stat-label">IS Standards</span> <span className="stat-label">{t("home.statStandards")}</span>
</div> </div>
<div className="stat-divider" aria-hidden="true" /> <div className="stat-divider" aria-hidden="true" />
<div className="stat"> <div className="stat">
<span className="stat-num">{stats.totalCategories}</span> <span className="stat-num">{stats.totalCategories}</span>
<span className="stat-label">Categories</span> <span className="stat-label">{t("home.statCategories")}</span>
</div> </div>
<div className="stat-divider" aria-hidden="true" /> <div className="stat-divider" aria-hidden="true" />
<div className="stat"> <div className="stat">
<span className="stat-num">929</span> <span className="stat-num">929</span>
<span className="stat-label">Pages Indexed</span> <span className="stat-label">{t("home.statPages")}</span>
</div> </div>
</div> </div>
)} )}
</div> </div>
</section> </section>
{/* Categories */}
<section className="tile tile-parchment" id="categories" aria-labelledby="cat-heading"> <section className="tile tile-parchment" id="categories" aria-labelledby="cat-heading">
<div className="tile-inner"> <div className="tile-inner">
<div className="section-header"> <div className="section-header">
<h2 className="display-lg" id="cat-heading">25 Material Categories</h2> <h2 className="display-lg" id="cat-heading">{t("home.categoriesHeading")}</h2>
<p className="lead-sub">Every building material section from SP21, indexed and searchable.</p> <p className="lead-sub">{t("home.categoriesLead")}</p>
</div> </div>
<div className="category-grid" role="list"> <div className="category-grid" role="list">
{categories.map((cat) => ( {categories.map((cat) => (
@@ -86,43 +92,35 @@ export default function Home() {
onClick={() => navigate(`/standards?category=${encodeURIComponent(cat.name)}`)} onClick={() => navigate(`/standards?category=${encodeURIComponent(cat.name)}`)}
> >
<span className="cat-name">{cat.name}</span> <span className="cat-name">{cat.name}</span>
<span className="cat-count">{cat.count} standard{cat.count !== 1 ? "s" : ""}</span> <span className="cat-count">{t("home.standardCount", { count: cat.count })}</span>
</button> </button>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* About strip */} <section className="tile tile-dark-2" aria-labelledby="about-strip-heading">
<section className="tile tile-dark-2" aria-labelledby="about-heading">
<div className="tile-inner"> <div className="tile-inner">
<div className="feature-cols"> <div className="feature-cols">
<div className="feature-text"> <div className="feature-text">
<p className="tile-eyebrow">About SP21</p> <p className="tile-eyebrow">{t("home.aboutEyebrow")}</p>
<h2 className="display-md" id="about-heading"> <h2 className="display-md" id="about-strip-heading">{t("home.aboutHeading")}</h2>
India's Reference for Building Material Standards <p className="body-copy">{t("home.aboutBody")}</p>
</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 <a
className="btn-primary-on-dark" className="btn-primary-on-dark"
href="https://www.bis.gov.in" href="https://www.bis.gov.in"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Visit BIS Portal ↗ {t("home.visitBIS")}
</a> </a>
</div> </div>
<div className="feature-pillars" role="list"> <div className="feature-pillars" role="list">
{PILLARS.map(({ icon, title, body }) => ( {PILLARS.map(({ icon, titleKey, bodyKey }) => (
<div className="pillar" role="listitem" key={title}> <div className="pillar" role="listitem" key={titleKey}>
<span className="pillar-icon" aria-hidden="true">{icon}</span> <span className="pillar-icon" aria-hidden="true">{icon}</span>
<h3 className="pillar-title">{title}</h3> <h3 className="pillar-title">{t(titleKey)}</h3>
<p className="pillar-body">{body}</p> <p className="pillar-body">{t(bodyKey)}</p>
</div> </div>
))} ))}
</div> </div>
@@ -133,13 +131,6 @@ export default function Home() {
); );
} }
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() { function SearchIcon() {
return ( return (
<svg className="search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true"> <svg className="search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
+35 -42
View File
@@ -1,9 +1,11 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { recommend } from "../api/standards"; import { recommend } from "../api/standards";
import StandardModal from "../components/StandardModal"; import StandardModal from "../components/StandardModal";
import "./Recommend.css"; import "./Recommend.css";
export default function Recommend() { export default function Recommend() {
const { t } = useTranslation();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [rewrite, setRewrite] = useState(false); const [rewrite, setRewrite] = useState(false);
const [results, setResults] = useState(null); const [results, setResults] = useState(null);
@@ -25,7 +27,7 @@ export default function Recommend() {
const data = await recommend({ query: q, top_n: 5, rewrite }); const data = await recommend({ query: q, top_n: 5, rewrite });
setResults(data); setResults(data);
} catch (err) { } catch (err) {
setError(err.message || "Something went wrong. Is the server running?"); setError(err.message || t("common.serverError"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -40,22 +42,17 @@ export default function Recommend() {
return ( return (
<main className="recommend-page"> <main className="recommend-page">
{/* Header tile */}
<section className="tile tile-dark rec-hero" aria-labelledby="rec-heading"> <section className="tile tile-dark rec-hero" aria-labelledby="rec-heading">
<div className="tile-inner tile-center"> <div className="tile-inner tile-center">
<p className="tile-eyebrow">Hybrid Retrieval · AI Explanation</p> <p className="tile-eyebrow">{t("recommend.eyebrow")}</p>
<h1 className="hero-display" id="rec-heading">Find & Understand Standards</h1> <h1 className="hero-display" id="rec-heading">{t("recommend.heading")}</h1>
<p className="lead"> <p className="lead">{t("recommend.lead")}</p>
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> </div>
</section> </section>
{/* Search tile */} <section className="tile tile-parchment" aria-label={t("recommend.eyebrow")}>
<section className="tile tile-parchment" aria-label="Recommendation search">
<div className="tile-inner"> <div className="tile-inner">
<form onSubmit={handleSubmit} role="search" aria-label="Recommend standards"> <form onSubmit={handleSubmit} role="search" aria-label={t("recommend.heading")}>
<div className="rec-search-wrap"> <div className="rec-search-wrap">
<SearchIcon /> <SearchIcon />
<input <input
@@ -64,8 +61,8 @@ export default function Recommend() {
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. What standard covers tensile strength of structural steel?" placeholder={t("recommend.searchPlaceholder")}
aria-label="Search query" aria-label={t("recommend.searchLabel")}
maxLength={500} maxLength={500}
disabled={loading} disabled={loading}
/> />
@@ -74,36 +71,35 @@ export default function Recommend() {
type="button" type="button"
className="rec-clear" className="rec-clear"
onClick={() => { setQuery(""); setResults(null); inputRef.current?.focus(); }} onClick={() => { setQuery(""); setResults(null); inputRef.current?.focus(); }}
aria-label="Clear" aria-label={t("recommend.clearBtn")}
></button> ></button>
)} )}
</div> </div>
<div className="rec-options-row"> <div className="rec-options-row">
<label className="rewrite-toggle" title="Let the AI rephrase your query into precise IS keywords before searching"> <label className="rewrite-toggle" title={t("recommend.rewriteHint")}>
<input <input
type="checkbox" type="checkbox"
checked={rewrite} checked={rewrite}
onChange={(e) => setRewrite(e.target.checked)} onChange={(e) => setRewrite(e.target.checked)}
disabled={loading} disabled={loading}
/> />
<span>Smart query rewrite</span> <span>{t("recommend.rewriteLabel")}</span>
<span className="rewrite-hint">AI refines your query before searching</span> <span className="rewrite-hint">{t("recommend.rewriteHint")}</span>
</label> </label>
<button <button
className="btn-primary rec-submit" className="btn-primary rec-submit"
type="submit" type="submit"
disabled={!query.trim() || loading} disabled={!query.trim() || loading}
> >
{loading ? <><SpinIcon /> Searching</> : "Find Standards"} {loading ? <><SpinIcon /> {t("recommend.submitting")}</> : t("recommend.submitBtn")}
</button> </button>
</div> </div>
</form> </form>
{/* Example queries */}
{!results && !loading && ( {!results && !loading && (
<div className="example-queries" aria-label="Example queries"> <div className="example-queries" aria-label={t("recommend.exampleLabel")}>
<p className="example-label">Try an example:</p> <p className="example-label">{t("recommend.exampleLabel")}</p>
<div className="example-chips"> <div className="example-chips">
{EXAMPLE_QUERIES.map((q) => ( {EXAMPLE_QUERIES.map((q) => (
<button <button
@@ -120,21 +116,20 @@ export default function Recommend() {
</div> </div>
</section> </section>
{/* Results tile */}
{(loading || results || error) && ( {(loading || results || error) && (
<section className="tile tile-light results-section" aria-live="polite" aria-label="Results"> <section className="tile tile-light results-section" aria-live="polite" aria-label={t("recommend.heading")}>
<div className="tile-inner"> <div className="tile-inner">
{error && ( {error && (
<div className="error-banner" role="alert"> <div className="error-banner" role="alert">
<strong>Error:</strong> {error} <strong>{t("recommend.error_prefix")}</strong> {error}
</div> </div>
)} )}
{loading && ( {loading && (
<div className="loading-state" aria-label="Loading results"> <div className="loading-state" aria-label={t("common.loading")}>
<div className="loading-steps"> <div className="loading-steps">
<LoadingStep icon="🔍" label="Running hybrid retrieval (FAISS + BM25)…" /> <LoadingStep icon="🔍" label={t("recommend.loadingRetrieval")} />
<LoadingStep icon="✦" label="Generating AI explanations…" delay /> <LoadingStep icon="✦" label={t("recommend.loadingAI")} delay />
</div> </div>
</div> </div>
)} )}
@@ -144,14 +139,14 @@ export default function Recommend() {
<div className="results-header"> <div className="results-header">
<div> <div>
<h2 className="results-title"> <h2 className="results-title">
{results.standards.length} Standard{results.standards.length !== 1 ? "s" : ""} Found {t("recommend.resultsFound", { count: results.standards.length })}
</h2> </h2>
<p className="results-query">for: <em>{results.query}</em></p> <p className="results-query">{t("recommend.resultsFor")} <em>{results.query}</em></p>
</div> </div>
<div className="latency-badge" aria-label="Timing breakdown"> <div className="latency-badge" aria-label={t("recommend.timingLabel")}>
<LatencyBadge label="Retrieval" ms={results.latency.retrieval_ms} /> <LatencyBadge label={t("recommend.retrieval")} ms={results.latency.retrieval_ms} />
<LatencyBadge label="AI" ms={results.latency.llm_ms} accent /> <LatencyBadge label={t("recommend.ai")} ms={results.latency.llm_ms} accent />
<LatencyBadge label="Total" ms={results.latency.total_ms} bold /> <LatencyBadge label={t("recommend.total")} ms={results.latency.total_ms} bold />
</div> </div>
</div> </div>
@@ -162,6 +157,7 @@ export default function Recommend() {
standard={s} standard={s}
rank={i + 1} rank={i + 1}
onOpen={() => setSelected(standardsFullRecord(s))} onOpen={() => setSelected(standardsFullRecord(s))}
t={t}
/> />
))} ))}
</div> </div>
@@ -178,9 +174,7 @@ export default function Recommend() {
); );
} }
// ── Sub-components ────────────────────────────────────────────────────────── function RecommendCard({ standard, rank, onOpen, t }) {
function RecommendCard({ standard, rank, onOpen }) {
return ( return (
<article <article
className="rec-card" className="rec-card"
@@ -188,7 +182,7 @@ function RecommendCard({ standard, rank, onOpen }) {
onClick={onOpen} onClick={onOpen}
onKeyDown={(e) => e.key === "Enter" && onOpen()} onKeyDown={(e) => e.key === "Enter" && onOpen()}
tabIndex={0} tabIndex={0}
aria-label={`Rank ${rank}: ${standard.standard_id}`} aria-label={t("recommend.rankLabel", { rank, id: standard.standard_id })}
> >
<div className="rec-card-rank" aria-hidden="true">{rank}</div> <div className="rec-card-rank" aria-hidden="true">{rank}</div>
@@ -197,21 +191,21 @@ function RecommendCard({ standard, rank, onOpen }) {
<span className="card-cat">{standard.category}</span> <span className="card-cat">{standard.category}</span>
<span className="card-id">{standard.standard_id}</span> <span className="card-id">{standard.standard_id}</span>
{standard.matched_section && ( {standard.matched_section && (
<span className="rec-card-section">§ {standard.matched_section}</span> <span className="rec-card-section">{t("recommend.section", { section: standard.matched_section })}</span>
)} )}
</div> </div>
<h3 className="rec-card-title">{standard.title}</h3> <h3 className="rec-card-title">{standard.title}</h3>
{standard.explanation && ( {standard.explanation && (
<div className="rec-card-explanation" aria-label="AI explanation"> <div className="rec-card-explanation" aria-label={t("recommend.aiExplanation")}>
<span className="explanation-icon" aria-hidden="true"></span> <span className="explanation-icon" aria-hidden="true"></span>
<p className="explanation-text">{standard.explanation}</p> <p className="explanation-text">{standard.explanation}</p>
</div> </div>
)} )}
{standard.keywords?.length > 0 && ( {standard.keywords?.length > 0 && (
<div className="card-keywords" aria-label="Keywords"> <div className="card-keywords" aria-label={t("recommend.keywords")}>
{standard.keywords.slice(0, 5).map((kw) => ( {standard.keywords.slice(0, 5).map((kw) => (
<span className="keyword-chip" key={kw}>{kw}</span> <span className="keyword-chip" key={kw}>{kw}</span>
))} ))}
@@ -219,7 +213,7 @@ function RecommendCard({ standard, rank, onOpen }) {
)} )}
</div> </div>
<div className="rec-card-score" aria-label={`Relevance score ${standard.score}`}> <div className="rec-card-score" aria-label={t("recommend.relevanceScore", { score: standard.score })}>
<span className="score-num">{(standard.score * 100).toFixed(0)}</span> <span className="score-num">{(standard.score * 100).toFixed(0)}</span>
<span className="score-label">score</span> <span className="score-label">score</span>
</div> </div>
@@ -260,7 +254,6 @@ function SpinIcon() {
return <span className="spin-icon" aria-hidden="true"></span>; return <span className="spin-icon" aria-hidden="true"></span>;
} }
// Merge recommendation result with full standard record for the modal
function standardsFullRecord(s) { function standardsFullRecord(s) {
return { return {
standard_id: s.standard_id, standard_id: s.standard_id,
+35 -29
View File
@@ -1,21 +1,20 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { fetchStandards, fetchCategories } from "../api/standards"; import { fetchStandards, fetchCategories } from "../api/standards";
import StandardCard from "../components/StandardCard"; import StandardCard from "../components/StandardCard";
import StandardModal from "../components/StandardModal"; import StandardModal from "../components/StandardModal";
import { useDebounce } from "../hooks/useDebounce";
import "./Standards.css"; import "./Standards.css";
const PAGE_SIZE = 18; const PAGE_SIZE = 18;
/**
* Standards search page with pagination.
* Uses URL search params for query/category state.
* Loads standards on mount and handles user interactions.
*/
export default function Standards() { export default function Standards() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") || ""); const [query, setQuery] = useState(searchParams.get("q") || "");
const debouncedQuery = useDebounce(query, 350);
const [category, setCategory] = useState(searchParams.get("category") || ""); const [category, setCategory] = useState(searchParams.get("category") || "");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -35,32 +34,37 @@ export default function Standards() {
setMeta(data.meta); setMeta(data.meta);
setPage(pg); setPage(pg);
} catch { } catch {
setError("Could not load standards. Is the server running?"); setError(t("standards.serverError"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
fetchCategories().then(setCategories).catch(() => {}); fetchCategories().then(setCategories).catch(() => {});
load(query, category, 1); load(debouncedQuery, category, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
load(debouncedQuery, category, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery]);
useEffect(() => { useEffect(() => {
const params = {}; const params = {};
if (query) params.q = query; if (debouncedQuery) params.q = debouncedQuery;
if (category) params.category = category; if (category) params.category = category;
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
}, [query, category, setSearchParams]); }, [debouncedQuery, category, setSearchParams]);
const handleCategoryChange = (value) => { const handleCategoryChange = (value) => {
setCategory(value); setCategory(value);
load(query, value, 1); load(debouncedQuery, value, 1);
}; };
const handlePageChange = (pg) => { const handlePageChange = (pg) => {
load(query, category, pg); load(debouncedQuery, category, pg);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}; };
@@ -74,8 +78,8 @@ export default function Standards() {
<main className="standards-page"> <main className="standards-page">
<section className="tile tile-parchment search-tile" aria-labelledby="search-heading"> <section className="tile tile-parchment search-tile" aria-labelledby="search-heading">
<div className="tile-inner"> <div className="tile-inner">
<h1 className="display-lg" id="search-heading">Find an IS Standard</h1> <h1 className="display-lg" id="search-heading">{t("standards.heading")}</h1>
<p className="lead-sub">Search by standard number, title, material, or keyword.</p> <p className="lead-sub">{t("standards.lead")}</p>
<form className="search-form" role="search" onSubmit={(e) => e.preventDefault()}> <form className="search-form" role="search" onSubmit={(e) => e.preventDefault()}>
<div className="search-wrap"> <div className="search-wrap">
@@ -85,15 +89,15 @@ export default function Standards() {
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. Ordinary Portland Cement, IS 269, aggregates…" placeholder={t("standards.searchPlaceholder")}
aria-label="Search standards" aria-label={t("standards.searchLabel")}
/> />
{query && ( {query && (
<button <button
className="search-clear" className="search-clear"
type="button" type="button"
onClick={handleClearSearch} onClick={handleClearSearch}
aria-label="Clear search" aria-label={t("standards.clearSearch")}
> >
</button> </button>
@@ -104,9 +108,9 @@ export default function Standards() {
className="category-filter" className="category-filter"
value={category} value={category}
onChange={(e) => handleCategoryChange(e.target.value)} onChange={(e) => handleCategoryChange(e.target.value)}
aria-label="Filter by category" aria-label={t("standards.categoryFilter")}
> >
<option value="">All Categories</option> <option value="">{t("standards.allCategories")}</option>
{categories.map((c) => ( {categories.map((c) => (
<option key={c.name} value={c.name}> <option key={c.name} value={c.name}>
{c.name} ({c.count}) {c.name} ({c.count})
@@ -124,8 +128,10 @@ export default function Standards() {
{!error && meta && ( {!error && meta && (
<p className="results-meta"> <p className="results-meta">
{loading ? "Searching…" : `${meta.total} standard${meta.total !== 1 ? "s" : ""} found`} {loading
{meta.total > 0 && ` — page ${meta.page} of ${meta.totalPages}`} ? t("standards.searching")
: t("standards.found", { count: meta.total })}
{!loading && meta.total > 0 && `${t("standards.page", { page: meta.page, total: meta.totalPages })}`}
</p> </p>
)} )}
@@ -139,8 +145,8 @@ export default function Standards() {
{!loading && results.length === 0 && !error && ( {!loading && results.length === 0 && !error && (
<div className="results-empty"> <div className="results-empty">
<p className="empty-title">No standards found</p> <p className="empty-title">{t("standards.noResultsTitle")}</p>
<p className="empty-sub">Try a different keyword or clear the category filter.</p> <p className="empty-sub">{t("standards.noResultsSub")}</p>
</div> </div>
)} )}
@@ -157,14 +163,14 @@ export default function Standards() {
)} )}
{meta && meta.totalPages > 1 && ( {meta && meta.totalPages > 1 && (
<nav className="pagination" aria-label="Results pagination"> <nav className="pagination" aria-label={t("standards.pagination")}>
<button <button
className="page-btn" className="page-btn"
disabled={page <= 1} disabled={page <= 1}
onClick={() => handlePageChange(page - 1)} onClick={() => handlePageChange(page - 1)}
aria-label="Previous page" aria-label={t("standards.prevPage")}
> >
Prev {t("standards.prevPage")}
</button> </button>
<div className="page-numbers"> <div className="page-numbers">
{buildPageRange(page, meta.totalPages).map((p, i) => {buildPageRange(page, meta.totalPages).map((p, i) =>
@@ -175,7 +181,7 @@ export default function Standards() {
key={p} key={p}
className={`page-btn${p === page ? " active" : ""}`} className={`page-btn${p === page ? " active" : ""}`}
onClick={() => handlePageChange(p)} onClick={() => handlePageChange(p)}
aria-label={`Page ${p}`} aria-label={t("standards.pageLabel", { page: p })}
aria-current={p === page ? "page" : undefined} aria-current={p === page ? "page" : undefined}
> >
{p} {p}
@@ -187,9 +193,9 @@ export default function Standards() {
className="page-btn" className="page-btn"
disabled={page >= meta.totalPages} disabled={page >= meta.totalPages}
onClick={() => handlePageChange(page + 1)} onClick={() => handlePageChange(page + 1)}
aria-label="Next page" aria-label={t("standards.nextPage")}
> >
Next {t("standards.nextPage")}
</button> </button>
</nav> </nav>
)} )}