feat: add web client frontend with monorepo config.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/* App-level styles — global layout only */
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Footer from "./components/Footer";
|
||||
import Home from "./pages/Home";
|
||||
import Standards from "./pages/Standards";
|
||||
import Categories from "./pages/Categories";
|
||||
import About from "./pages/About";
|
||||
import Recommend from "./pages/Recommend";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/standards" element={<Standards />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/recommend" element={<Recommend />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const BASE = "/api";
|
||||
|
||||
// Safe fetch: always reads body as text first, then parses JSON.
|
||||
// If the server returns HTML (e.g. 404 from a stale process or proxy miss),
|
||||
// this surfaces a clear error instead of "Unexpected token '<'".
|
||||
async function safeFetch(url, options = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, options);
|
||||
} catch (networkErr) {
|
||||
throw new Error(`Network error — is the server running? (${networkErr.message})`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
// Try JSON parse
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
// Server returned non-JSON (HTML error page, proxy 502, etc.)
|
||||
const preview = text.slice(0, 120).replace(/<[^>]+>/g, "").trim();
|
||||
throw new Error(
|
||||
`Server returned ${res.status} ${res.statusText}` +
|
||||
(preview ? `: ${preview}` : "")
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchStandards({ q = "", category = "", page = 1, limit = 18 } = {}) {
|
||||
const params = new URLSearchParams({ q, category, page, limit });
|
||||
return safeFetch(`${BASE}/standards?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchStandard(id) {
|
||||
return safeFetch(`${BASE}/standards/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function fetchCategories() {
|
||||
return safeFetch(`${BASE}/categories`);
|
||||
}
|
||||
|
||||
export async function fetchStats() {
|
||||
return safeFetch(`${BASE}/stats`);
|
||||
}
|
||||
|
||||
export async function askQuestion({ standard_id, question }) {
|
||||
return safeFetch(`${BASE}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ standard_id, question }),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/recommend — hybrid retrieval + LLM explanations
|
||||
export async function recommend({ query, top_n = 5, rewrite = false } = {}) {
|
||||
return safeFetch(`${BASE}/recommend`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, top_n, rewrite }),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/ask — chunk-grounded QA for a specific standard
|
||||
export async function askGrounded({ standard_id, question } = {}) {
|
||||
return safeFetch(`${BASE}/ask`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ standard_id, question }),
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,52 @@
|
||||
.footer {
|
||||
background: var(--parchment);
|
||||
border-top: 1px solid var(--divider);
|
||||
padding: 56px 0 28px;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
.footer-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.footer-brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.footer-tagline { font-size: 13px; color: var(--ink-48); line-height: 1.5; }
|
||||
.footer-heading { font-size: 13px; font-weight: 600; color: var(--ink-80); margin-bottom: 10px; }
|
||||
.footer-link {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--ink-48);
|
||||
text-decoration: none;
|
||||
line-height: 2.2;
|
||||
transition: color .12s;
|
||||
}
|
||||
.footer-link:hover { color: var(--accent); }
|
||||
.footer-legal {
|
||||
border-top: 1px solid var(--divider);
|
||||
padding-top: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-48);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.footer-legal p + p { margin-top: 4px; }
|
||||
.legal-link { color: var(--ink-48); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.legal-link:hover { color: var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.footer-cols { grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.footer-cols { grid-template-columns: 1fr; }
|
||||
.footer-inner { padding: 0 20px; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./Footer.css";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer" role="contentinfo">
|
||||
<div className="footer-inner">
|
||||
<div className="footer-cols">
|
||||
<div className="footer-col">
|
||||
<p className="footer-brand">BIS SP‑21</p>
|
||||
<p className="footer-tagline">
|
||||
Handbook on Building Materials<br />
|
||||
Special Publication 21 : 2005
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<p className="footer-heading">Portal</p>
|
||||
<Link className="footer-link" to="/standards">Search Standards</Link>
|
||||
<Link className="footer-link" to="/categories">Browse Categories</Link>
|
||||
<Link className="footer-link" to="/about">About</Link>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<p className="footer-heading">Bureau of Indian Standards</p>
|
||||
<a className="footer-link" href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer">BIS Official Website</a>
|
||||
<a className="footer-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">Manak Online</a>
|
||||
<a className="footer-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">Standards Portal</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<p className="footer-heading">Ministry</p>
|
||||
<a className="footer-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">DPIIT</a>
|
||||
<a className="footer-link" href="https://www.india.gov.in" target="_blank" rel="noopener noreferrer">National Portal</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-legal">
|
||||
<p>© Bureau of Indian Standards, Ministry of Commerce & Industry, Government of India. All rights reserved.</p>
|
||||
<p>Content sourced from BIS Special Publication 21 : 2005. For official standards, refer to{" "}
|
||||
<a href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer" className="legal-link">bis.gov.in</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.global-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
background: var(--surface-black);
|
||||
height: var(--nav-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
.nav-inner {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-lg);
|
||||
}
|
||||
.nav-emblem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.emblem-icon { width: 28px; height: 28px; }
|
||||
.nav-brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--on-dark);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-link {
|
||||
font-family: var(--font-text);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.12px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.nav-link:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.nav-link.active { color: #fff; }
|
||||
|
||||
.nav-hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-hamburger span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--on-dark);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.mobile-menu {
|
||||
position: sticky;
|
||||
top: var(--nav-h);
|
||||
z-index: 190;
|
||||
background: var(--tile-dark-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.mobile-link {
|
||||
display: block;
|
||||
padding: 14px 32px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
transition: background .15s;
|
||||
}
|
||||
.mobile-link:hover { background: rgba(255,255,255,0.05); }
|
||||
|
||||
@media (max-width: 833px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.nav-inner { padding: 0 20px; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import "./Navbar.css";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: "Standards", to: "/standards" },
|
||||
{ label: "Categories", to: "/categories" },
|
||||
{ label: "✦ AI Recommend", to: "/recommend" },
|
||||
{ label: "About", to: "/about" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="global-nav" role="navigation" aria-label="Primary navigation">
|
||||
<div className="nav-inner">
|
||||
<Link className="nav-emblem" to="/" aria-label="BIS SP-21 home" onClick={() => setOpen(false)}>
|
||||
<BISIcon />
|
||||
<span className="nav-brand">BIS SP‑21</span>
|
||||
</Link>
|
||||
|
||||
<div className="nav-links" role="list">
|
||||
{NAV_LINKS.map(({ label, to }) => (
|
||||
<Link
|
||||
key={to}
|
||||
className={`nav-link${pathname === to ? " active" : ""}`}
|
||||
to={to}
|
||||
role="listitem"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
className="nav-link"
|
||||
href="https://www.bis.gov.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="listitem"
|
||||
>
|
||||
BIS Portal ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="nav-hamburger"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{open && (
|
||||
<div className="mobile-menu" id="mobile-menu" role="menu">
|
||||
{NAV_LINKS.map(({ label, to }) => (
|
||||
<Link
|
||||
key={to}
|
||||
className="mobile-link"
|
||||
to={to}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
className="mobile-link"
|
||||
href="https://www.bis.gov.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
BIS Portal ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BISIcon() {
|
||||
return (
|
||||
<svg className="emblem-icon" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<circle cx="18" cy="18" r="16" stroke="#FF9933" strokeWidth="2.5" />
|
||||
<circle cx="18" cy="18" r="6" fill="#FF9933" />
|
||||
<path d="M18 4v4M18 28v4M4 18h4M28 18h4" stroke="#FF9933" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M8.7 8.7l2.8 2.8M24.5 24.5l2.8 2.8M8.7 27.3l2.8-2.8M24.5 11.5l2.8-2.8"
|
||||
stroke="#FF9933" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.result-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--sp-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.result-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 24px rgba(212,83,10,0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.result-card:active { transform: scale(.98); }
|
||||
.result-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.card-cat {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--chip-text);
|
||||
background: var(--chip-bg);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px 10px;
|
||||
margin-bottom: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
.card-id {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-summary {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-48);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
.card-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.keyword-chip {
|
||||
font-size: 11px;
|
||||
color: var(--ink-80);
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
.card-sections-count { font-size: 12px; color: var(--ink-48); }
|
||||
.card-arrow { color: var(--accent); font-size: 14px; font-weight: 600; }
|
||||
@@ -0,0 +1,36 @@
|
||||
import "./StandardCard.css";
|
||||
|
||||
export default function StandardCard({ standard, onClick }) {
|
||||
const sectionCount = Object.keys(standard.key_sections || {}).length;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="result-card"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick()}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`View details for ${standard.standard_id}`}
|
||||
>
|
||||
<span className="card-cat">{standard.category}</span>
|
||||
<p className="card-id">{standard.standard_id}</p>
|
||||
<h3 className="card-title">{standard.title}</h3>
|
||||
{standard.summary && (
|
||||
<p className="card-summary">{standard.summary}</p>
|
||||
)}
|
||||
{standard.keywords?.length > 0 && (
|
||||
<div className="card-keywords" aria-label="Keywords">
|
||||
{standard.keywords.slice(0, 5).map((kw) => (
|
||||
<span className="keyword-chip" key={kw}>{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<span className="card-sections-count">
|
||||
{sectionCount} section{sectionCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="card-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
background: rgba(13,17,23,0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
animation: fadeIn .18s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
background: var(--canvas);
|
||||
border-radius: var(--r-lg);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp .2s ease;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 28px 28px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--canvas);
|
||||
z-index: 1;
|
||||
}
|
||||
.modal-eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--chip-text);
|
||||
background: var(--chip-bg);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal-id {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-48);
|
||||
}
|
||||
.modal-close {
|
||||
flex-shrink: 0;
|
||||
background: var(--parchment);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--ink-48);
|
||||
font-size: 14px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.modal-close:hover { background: var(--divider); color: var(--ink); }
|
||||
|
||||
.modal-body { padding: 24px 28px 32px; }
|
||||
.modal-section { margin-bottom: 24px; }
|
||||
.modal-section:last-child { margin-bottom: 0; }
|
||||
.modal-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-section-body {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--ink-80);
|
||||
}
|
||||
.modal-keywords { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.modal-keyword {
|
||||
font-size: 12px;
|
||||
color: var(--ink-80);
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.modal-key-sections { display: flex; flex-direction: column; gap: 16px; }
|
||||
.key-section-item {
|
||||
background: var(--parchment);
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.key-section-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.key-section-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-80);
|
||||
}
|
||||
|
||||
/* ── AI Chat Panel ── */
|
||||
.ai-panel {
|
||||
background: linear-gradient(135deg, rgba(212,83,10,0.04) 0%, rgba(26,34,48,0.04) 100%);
|
||||
border: 1px solid rgba(212,83,10,0.15);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ai-label-icon {
|
||||
color: var(--accent);
|
||||
margin-right: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Suggestion chips */
|
||||
.chat-suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.suggestion-chip {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 14px;
|
||||
font-family: var(--font-text);
|
||||
font-size: 13px;
|
||||
color: var(--ink-80);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.suggestion-chip:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(212,83,10,0.04);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Chat log */
|
||||
.chat-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.chat-bubble {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
max-width: 92%;
|
||||
}
|
||||
.chat-bubble--user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.chat-bubble--user .bubble-text {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--r-md) var(--r-md) 4px var(--r-md);
|
||||
}
|
||||
.chat-bubble--ai .bubble-text {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--divider);
|
||||
color: var(--ink-80);
|
||||
border-radius: var(--r-md) var(--r-md) var(--r-md) 4px;
|
||||
}
|
||||
.bubble-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 10px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.bubble-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Typing dots */
|
||||
.chat-bubble--loading .bubble-text { display: none; }
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--divider);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--r-md) var(--r-md) var(--r-md) 4px;
|
||||
}
|
||||
.typing-dots span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.typing-dots span:nth-child(2) { animation-delay: .2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: .5; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
/* Chat input */
|
||||
.chat-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 10px 16px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.chat-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(212,83,10,0.1);
|
||||
}
|
||||
.chat-input:disabled { opacity: 0.6; }
|
||||
.chat-input::placeholder { color: var(--body-muted); }
|
||||
|
||||
.chat-send {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--r-pill);
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-send:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.chat-send:active:not(:disabled) { transform: scale(.95); }
|
||||
.chat-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.modal-backdrop { align-items: flex-end; padding: 0; }
|
||||
.modal { border-radius: var(--r-md) var(--r-md) 0 0; max-height: 90vh; }
|
||||
.chat-form { flex-direction: column; align-items: stretch; }
|
||||
.chat-send { text-align: center; }
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { askQuestion } from "../api/standards";
|
||||
import "./StandardModal.css";
|
||||
|
||||
export default function StandardModal({ standard, onClose }) {
|
||||
const modalRef = useRef(null);
|
||||
const closeBtnRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [question, setQuestion] = useState("");
|
||||
const [messages, setMessages] = useState([]); // [{role, text}]
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [aiError, setAiError] = useState(null);
|
||||
const chatEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
closeBtnRef.current?.focus();
|
||||
const onKey = (e) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, asking]);
|
||||
|
||||
const handleBackdrop = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
const handleAsk = async (e) => {
|
||||
e.preventDefault();
|
||||
const q = question.trim();
|
||||
if (!q || asking) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", text: q }]);
|
||||
setQuestion("");
|
||||
setAsking(true);
|
||||
setAiError(null);
|
||||
|
||||
try {
|
||||
const { answer } = await askQuestion({ standard_id: standard.standard_id, question: q });
|
||||
setMessages((prev) => [...prev, { role: "ai", text: answer }]);
|
||||
} catch (err) {
|
||||
setAiError(err.message || "Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setAsking(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
};
|
||||
|
||||
if (!standard) return null;
|
||||
|
||||
const sections = Object.entries(standard.key_sections || {});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
className="modal"
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<span className="modal-eyebrow">{standard.category}</span>
|
||||
<h2 className="modal-title" id="modal-title">{standard.title}</h2>
|
||||
<span className="modal-id">{standard.standard_id}</span>
|
||||
</div>
|
||||
<button
|
||||
className="modal-close"
|
||||
ref={closeBtnRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close standard detail"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Standard detail */}
|
||||
<div className="modal-body">
|
||||
{standard.summary && (
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Summary</p>
|
||||
<p className="modal-section-body">{standard.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{standard.keywords?.length > 0 && (
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Keywords</p>
|
||||
<div className="modal-keywords">
|
||||
{standard.keywords.map((kw) => (
|
||||
<span className="modal-keyword" key={kw}>{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 0 && (
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Key Sections</p>
|
||||
<div className="modal-key-sections">
|
||||
{sections.map(([name, text]) => (
|
||||
<div className="key-section-item" key={name}>
|
||||
<p className="key-section-name">{name}</p>
|
||||
<p className="key-section-text">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI chat panel */}
|
||||
<div className="modal-section ai-panel" aria-label="Ask AI about this standard">
|
||||
<p className="modal-section-title">
|
||||
<span className="ai-label-icon" aria-hidden="true">✦</span>
|
||||
Ask AI about this standard
|
||||
</p>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="chat-log" aria-live="polite" aria-label="Conversation">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`chat-bubble chat-bubble--${m.role}`}>
|
||||
{m.role === "ai" && (
|
||||
<span className="bubble-label" aria-label="AI response">✦</span>
|
||||
)}
|
||||
<p className="bubble-text">{m.text}</p>
|
||||
</div>
|
||||
))}
|
||||
{asking && (
|
||||
<div className="chat-bubble chat-bubble--ai chat-bubble--loading" aria-label="AI is thinking">
|
||||
<span className="bubble-label" aria-hidden="true">✦</span>
|
||||
<span className="typing-dots">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{aiError && (
|
||||
<p className="chat-error" role="alert">{aiError}</p>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !asking && (
|
||||
<div className="chat-suggestions" aria-label="Suggested questions">
|
||||
{getSuggestions(standard).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className="suggestion-chip"
|
||||
onClick={() => {
|
||||
setQuestion(s);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="chat-form" onSubmit={handleAsk} aria-label="Ask a question">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="Ask a question about this standard…"
|
||||
maxLength={500}
|
||||
disabled={asking}
|
||||
aria-label="Your question"
|
||||
/>
|
||||
<button
|
||||
className="chat-send"
|
||||
type="submit"
|
||||
disabled={!question.trim() || asking}
|
||||
aria-label="Send question"
|
||||
>
|
||||
{asking ? "…" : "Ask"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSuggestions(standard) {
|
||||
const base = [
|
||||
`What are the key requirements of ${standard.standard_id}?`,
|
||||
"What materials or tests are specified?",
|
||||
"What are the delivery or packaging specifications?",
|
||||
];
|
||||
if (standard.key_sections?.["Chemical Requirements"]) {
|
||||
base.splice(1, 0, "Summarise the chemical requirements.");
|
||||
}
|
||||
if (standard.key_sections?.["Physical Requirements"] || standard.key_sections?.["Physical Requirement"]) {
|
||||
base.splice(1, 0, "What are the physical requirements?");
|
||||
}
|
||||
return base.slice(0, 3);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce(value, delay = 300) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* ── BIS SP-21 Design Tokens ── */
|
||||
:root {
|
||||
--accent: #d4530a;
|
||||
--accent-hover: #b8460a;
|
||||
--accent-dark: #ff8c3a;
|
||||
--accent-focus: #e05a0f;
|
||||
|
||||
--navy: #003380;
|
||||
|
||||
--canvas: #ffffff;
|
||||
--parchment: #f4f4f2;
|
||||
--pearl: #fafaf8;
|
||||
--tile-dark-1: #1a2230;
|
||||
--tile-dark-2: #1e2838;
|
||||
--tile-dark-3: #161d28;
|
||||
--surface-black: #0d1117;
|
||||
|
||||
--ink: #1c1c1e;
|
||||
--ink-80: #2d2d30;
|
||||
--ink-48: #6e6e73;
|
||||
--on-dark: #ffffff;
|
||||
--on-dark-muted: #b8c0cc;
|
||||
--body-muted: #8a8a8f;
|
||||
|
||||
--hairline: #d8d8dc;
|
||||
--divider: #ebebed;
|
||||
|
||||
--chip-bg: rgba(212, 83, 10, 0.08);
|
||||
--chip-text: #b8460a;
|
||||
|
||||
--font-display: "SF Pro Display", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-text: "SF Pro Text", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
|
||||
--r-xs: 4px;
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 18px;
|
||||
--r-pill: 9999px;
|
||||
|
||||
--sp-xxs: 4px;
|
||||
--sp-xs: 8px;
|
||||
--sp-sm: 12px;
|
||||
--sp-md: 18px;
|
||||
--sp-lg: 24px;
|
||||
--sp-xl: 32px;
|
||||
--sp-xxl: 48px;
|
||||
--sp-sec: 80px;
|
||||
|
||||
--nav-h: 48px;
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.47;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Tiles (global) ── */
|
||||
.tile { width: 100%; padding: var(--sp-sec) 0; }
|
||||
.tile-light { background: var(--canvas); }
|
||||
.tile-parchment { background: var(--parchment); }
|
||||
.tile-dark { background: var(--tile-dark-1); }
|
||||
.tile-dark-2 { background: var(--tile-dark-2); }
|
||||
|
||||
.tile-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
.tile-center { text-align: center; }
|
||||
|
||||
/* ── Typography (global) ── */
|
||||
.hero-display {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(32px, 6vw, 56px);
|
||||
font-weight: 600;
|
||||
line-height: 1.07;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--on-dark);
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
.tile-eyebrow {
|
||||
font-family: var(--font-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-dark);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.display-lg {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(26px, 4vw, 40px);
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.tile-dark .display-lg,
|
||||
.tile-dark-2 .display-lg { color: var(--on-dark); }
|
||||
|
||||
.display-md {
|
||||
font-family: var(--font-text);
|
||||
font-size: clamp(22px, 3vw, 34px);
|
||||
font-weight: 600;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--on-dark);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.lead {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(17px, 2.5vw, 24px);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--on-dark-muted);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.lead-sub {
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.47;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--ink-48);
|
||||
margin: 8px 0 28px;
|
||||
}
|
||||
.body-copy {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--on-dark-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Buttons (global) ── */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.374px;
|
||||
text-decoration: none;
|
||||
padding: 11px 24px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { transform: scale(.96); }
|
||||
.btn-primary:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
|
||||
|
||||
.btn-ghost-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--accent-dark);
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1.5px solid var(--accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
}
|
||||
.btn-ghost-dark:hover { background: rgba(255,140,58,0.1); }
|
||||
.btn-ghost-dark:active { transform: scale(.96); }
|
||||
|
||||
.btn-primary-on-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-dark);
|
||||
color: var(--surface-black);
|
||||
font-family: var(--font-text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
padding: 11px 24px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-primary-on-dark:hover { background: #ffaa5c; }
|
||||
.btn-primary-on-dark:active { transform: scale(.96); }
|
||||
|
||||
/* Hero stats */
|
||||
.hero-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px 36px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.stat-num {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--on-dark-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stat-divider { width: 1px; height: 36px; background: rgba(255,255,255,0.12); }
|
||||
.desktop-only { display: inline; }
|
||||
|
||||
/* Focus */
|
||||
*:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 833px) {
|
||||
:root { --sp-sec: 56px; }
|
||||
.hero-stats { gap: 20px; padding: 16px 24px; }
|
||||
.stat-num { font-size: 22px; }
|
||||
.desktop-only { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:root { --sp-sec: 48px; }
|
||||
.tile-inner { padding: 0 20px; }
|
||||
.hero-stats { flex-direction: column; gap: 12px; padding: 16px 20px; }
|
||||
.stat-divider { width: 40px; height: 1px; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
.about-hero { padding: 72px 0 56px; }
|
||||
.about-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 56px;
|
||||
align-items: start;
|
||||
}
|
||||
.about-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--ink);
|
||||
margin: 36px 0 12px;
|
||||
}
|
||||
.about-main .about-section-title:first-child { margin-top: 0; }
|
||||
.about-body {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
letter-spacing: -0.374px;
|
||||
color: var(--ink-80);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.about-body em { font-style: italic; color: var(--ink); }
|
||||
|
||||
.about-stat-card,
|
||||
.about-links-card {
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sidebar-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-list dt { color: var(--ink-48); font-weight: 500; }
|
||||
.detail-list dd { color: var(--ink-80); font-weight: 400; }
|
||||
|
||||
.ext-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: var(--ink-80);
|
||||
text-decoration: none;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
transition: color .15s;
|
||||
}
|
||||
.ext-link:last-child { border-bottom: none; }
|
||||
.ext-link:hover { color: var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.about-content { grid-template-columns: 1fr; }
|
||||
.about-sidebar { order: -1; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.about-stat-card, .about-links-card { margin-bottom: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.about-sidebar { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import "./About.css";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<main>
|
||||
<section className="tile tile-dark about-hero" aria-labelledby="about-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">Bureau of Indian Standards</p>
|
||||
<h1 className="hero-display" id="about-heading">About BIS SP‑21</h1>
|
||||
<p className="lead">
|
||||
India's authoritative handbook on building and construction material standards.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tile tile-light" aria-label="About the publication">
|
||||
<div className="tile-inner about-content">
|
||||
<div className="about-main">
|
||||
<h2 className="about-section-title">What is SP‑21?</h2>
|
||||
<p className="about-body">
|
||||
BIS Special Publication 21 — <em>Handbook on Building Materials</em> — is a consolidated
|
||||
reference published by the Bureau of Indian Standards. It brings together all Indian
|
||||
Standards relevant to construction and building materials into a single, organised document.
|
||||
</p>
|
||||
<p className="about-body">
|
||||
The 2005 edition (the basis of this portal) spans 929 pages across 25 material sections,
|
||||
covering everything from cement and structural steel to timber, paints, sanitary fittings,
|
||||
wire ropes, and thermal insulation.
|
||||
</p>
|
||||
|
||||
<h2 className="about-section-title">Who uses it?</h2>
|
||||
<p className="about-body">
|
||||
SP‑21 is used daily by structural engineers specifying materials, architects selecting
|
||||
finishes, contractors verifying supplier compliance, quality inspectors conducting audits,
|
||||
and procurement officers evaluating bids. It is the single source of truth for which IS
|
||||
standard governs a given building product.
|
||||
</p>
|
||||
|
||||
<h2 className="about-section-title">About this portal</h2>
|
||||
<p className="about-body">
|
||||
This portal parses the SP‑21 : 2005 source document into 573 discrete IS standards with
|
||||
structured fields — standard ID, title, material category, scope summary, key sections
|
||||
(Requirements, Delivery, Manufacture, etc.), and TF-IDF keywords. Every record is
|
||||
full-text searchable and filterable by category.
|
||||
</p>
|
||||
<p className="about-body">
|
||||
The parser uses a two-pass boundary detection algorithm to split the PDF's continuous
|
||||
text into individual standards, with deduplication, section normalisation, and
|
||||
contamination detection to ensure clean, reliable data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside className="about-sidebar">
|
||||
<div className="about-stat-card">
|
||||
<h3 className="sidebar-heading">Publication Details</h3>
|
||||
<dl className="detail-list">
|
||||
<dt>Publisher</dt><dd>Bureau of Indian Standards</dd>
|
||||
<dt>Edition</dt><dd>SP 21 : 2005</dd>
|
||||
<dt>Pages</dt><dd>929</dd>
|
||||
<dt>Standards indexed</dt><dd>573</dd>
|
||||
<dt>Categories</dt><dd>25</dd>
|
||||
<dt>Ministry</dt><dd>DPIIT, Govt. of India</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="about-links-card">
|
||||
<h3 className="sidebar-heading">Official Links</h3>
|
||||
<a className="ext-link" href="https://www.bis.gov.in" target="_blank" rel="noopener noreferrer">
|
||||
<span>BIS Official Website</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<a className="ext-link" href="https://www.manakonline.in" target="_blank" rel="noopener noreferrer">
|
||||
<span>Manak Online</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<a className="ext-link" href="https://standardsbis.bsbedge.com" target="_blank" rel="noopener noreferrer">
|
||||
<span>Standards Portal</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<a className="ext-link" href="https://dpiit.gov.in" target="_blank" rel="noopener noreferrer">
|
||||
<span>DPIIT</span><span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.cat-hero { padding: 72px 0 56px; }
|
||||
|
||||
.cat-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.cat-page-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
grid-template-areas: "icon name" "icon count" "icon arrow";
|
||||
grid-template-columns: 48px 1fr;
|
||||
column-gap: 16px;
|
||||
row-gap: 2px;
|
||||
align-items: start;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
.cat-page-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 20px rgba(212,83,10,0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cat-page-card:active { transform: scale(.98); }
|
||||
.cat-page-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cat-page-icon {
|
||||
grid-area: icon;
|
||||
font-size: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--parchment);
|
||||
border-radius: var(--r-md);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.cat-page-name {
|
||||
grid-area: name;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
align-self: end;
|
||||
}
|
||||
.cat-page-count {
|
||||
grid-area: count;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cat-page-arrow {
|
||||
grid-area: arrow;
|
||||
font-size: 14px;
|
||||
color: var(--ink-48);
|
||||
align-self: end;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.cat-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: linear-gradient(90deg, var(--parchment) 25%, var(--divider) 50%, var(--parchment) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--divider);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cat-page-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchCategories } from "../api/standards";
|
||||
import "./Categories.css";
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
"Adhesives": "🧲",
|
||||
"Bitumen and Tar Products": "🛣️",
|
||||
"Builder's Hardware": "🔩",
|
||||
"Building Limes": "🪨",
|
||||
"Cement and Concrete": "🏗️",
|
||||
"Concrete Reinforcement": "⚙️",
|
||||
"Doors, Windows and Shutters": "🚪",
|
||||
"Electrical Installations": "⚡",
|
||||
"Floor, Wall, Roof Coverings and Finishes": "🏛️",
|
||||
"Gypsum Building Materials": "🏺",
|
||||
"Light Metal and Their Alloys": "🔧",
|
||||
"Paints, Varnishes and Allied Products": "🎨",
|
||||
"Pipes and Fittings": "🔧",
|
||||
"Sanitary Appliances and Water Fittings": "🚿",
|
||||
"Stones": "🪨",
|
||||
"Structural Shapes": "📐",
|
||||
"Structural Steels": "🏗️",
|
||||
"Thermal Insulation Materials": "🌡️",
|
||||
"Threaded Fasteners and Rivets": "🔩",
|
||||
"Timber": "🪵",
|
||||
"Water Proofing and Damp Proofing Materials": "💧",
|
||||
"Welding Electrodes and Wires": "🔌",
|
||||
"Wire Ropes and Wire Products": "🪢",
|
||||
"Wood Products": "🪵",
|
||||
"Wood Products for Building": "🏠",
|
||||
};
|
||||
|
||||
export default function Categories() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
.then(setCategories)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const total = categories.reduce((s, c) => s + c.count, 0);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="tile tile-dark cat-hero" aria-labelledby="cat-page-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">SP‑21 : 2005</p>
|
||||
<h1 className="hero-display" id="cat-page-heading">Material Categories</h1>
|
||||
<p className="lead">
|
||||
{total} standards across {categories.length} building material sections.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tile tile-light" aria-label="All categories">
|
||||
<div className="tile-inner">
|
||||
{loading ? (
|
||||
<div className="cat-skeleton">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div className="skeleton-card" key={i} aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cat-page-grid" role="list">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.name}
|
||||
className="cat-page-card"
|
||||
role="listitem"
|
||||
onClick={() => navigate(`/standards?category=${encodeURIComponent(cat.name)}`)}
|
||||
>
|
||||
<span className="cat-page-icon" aria-hidden="true">
|
||||
{CATEGORY_ICONS[cat.name] || "📋"}
|
||||
</span>
|
||||
<span className="cat-page-name">{cat.name}</span>
|
||||
<span className="cat-page-count">{cat.count} standards</span>
|
||||
<span className="cat-page-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
.hero-tile { padding: 96px 0 80px; }
|
||||
|
||||
.hero-search-form { width: 100%; max-width: 660px; margin: 0 auto 48px; }
|
||||
.hero-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1.5px solid rgba(255,255,255,0.15);
|
||||
border-radius: var(--r-pill);
|
||||
padding-right: 6px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.hero-search-wrap:focus-within {
|
||||
background: rgba(255,255,255,0.09);
|
||||
border-color: var(--accent-dark);
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-text);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--on-dark);
|
||||
padding: 14px 14px 14px 48px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.hero-search-input::placeholder { color: rgba(255,255,255,0.35); }
|
||||
.hero-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
|
||||
.hero-search-btn { font-size: 14px; padding: 9px 20px; }
|
||||
|
||||
.section-header { text-align: center; margin-bottom: 48px; }
|
||||
.section-header .lead-sub { max-width: 540px; margin: 8px auto 0; }
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.cat-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px var(--sp-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
.cat-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 20px rgba(212,83,10,0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cat-card:active { transform: scale(.98); }
|
||||
.cat-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cat-name { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.3; }
|
||||
.cat-count { font-size: 12px; color: var(--accent); font-weight: 500; }
|
||||
|
||||
.feature-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: start;
|
||||
}
|
||||
.feature-pillars {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.pillar {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: var(--r-md);
|
||||
padding: 20px;
|
||||
}
|
||||
.pillar-icon { font-size: 22px; display: block; margin-bottom: 10px; }
|
||||
.pillar-title { font-size: 14px; font-weight: 600; color: var(--on-dark); margin-bottom: 6px; }
|
||||
.pillar-body { font-size: 13px; line-height: 1.5; color: var(--on-dark-muted); }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.feature-cols { grid-template-columns: 1fr; gap: 40px; }
|
||||
}
|
||||
@media (max-width: 833px) {
|
||||
.feature-pillars { grid-template-columns: 1fr; }
|
||||
.hero-tile { padding: 64px 0 56px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hero-search-form { max-width: 100%; }
|
||||
.hero-search-wrap { flex-direction: column; border-radius: var(--r-lg); padding: 0; }
|
||||
.hero-search-input { width: 100%; padding: 14px 14px 14px 48px; }
|
||||
.hero-search-btn { width: 100%; border-radius: 0 0 var(--r-lg) var(--r-lg); text-align: center; justify-content: center; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchStats, fetchCategories } from "../api/standards";
|
||||
import "./Home.css";
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats().then(setStats).catch(() => {});
|
||||
fetchCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) navigate(`/standards?q=${encodeURIComponent(query.trim())}`);
|
||||
else navigate("/standards");
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="tile tile-dark hero-tile" aria-labelledby="hero-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">Special Publication 21 · 2005</p>
|
||||
<h1 className="hero-display" id="hero-heading">
|
||||
Handbook of<br />Building Materials
|
||||
</h1>
|
||||
<p className="lead">
|
||||
Indian Standards across 25 material categories —<br className="desktop-only" />
|
||||
searchable, categorised, and ready to reference.
|
||||
</p>
|
||||
|
||||
<form className="hero-search-form" onSubmit={handleSearch} role="search">
|
||||
<div className="hero-search-wrap">
|
||||
<SearchIcon />
|
||||
<input
|
||||
className="hero-search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search standards, e.g. Portland Cement, IS 269…"
|
||||
aria-label="Search standards"
|
||||
/>
|
||||
<button className="btn-primary hero-search-btn" type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{stats && (
|
||||
<div className="hero-stats" aria-label="Key statistics">
|
||||
<div className="stat">
|
||||
<span className="stat-num">{stats.totalStandards}</span>
|
||||
<span className="stat-label">IS Standards</span>
|
||||
</div>
|
||||
<div className="stat-divider" aria-hidden="true" />
|
||||
<div className="stat">
|
||||
<span className="stat-num">{stats.totalCategories}</span>
|
||||
<span className="stat-label">Categories</span>
|
||||
</div>
|
||||
<div className="stat-divider" aria-hidden="true" />
|
||||
<div className="stat">
|
||||
<span className="stat-num">929</span>
|
||||
<span className="stat-label">Pages Indexed</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section className="tile tile-parchment" id="categories" aria-labelledby="cat-heading">
|
||||
<div className="tile-inner">
|
||||
<div className="section-header">
|
||||
<h2 className="display-lg" id="cat-heading">25 Material Categories</h2>
|
||||
<p className="lead-sub">Every building material section from SP‑21, indexed and searchable.</p>
|
||||
</div>
|
||||
<div className="category-grid" role="list">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.name}
|
||||
className="cat-card"
|
||||
role="listitem"
|
||||
onClick={() => navigate(`/standards?category=${encodeURIComponent(cat.name)}`)}
|
||||
>
|
||||
<span className="cat-name">{cat.name}</span>
|
||||
<span className="cat-count">{cat.count} standard{cat.count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About strip */}
|
||||
<section className="tile tile-dark-2" aria-labelledby="about-heading">
|
||||
<div className="tile-inner">
|
||||
<div className="feature-cols">
|
||||
<div className="feature-text">
|
||||
<p className="tile-eyebrow">About SP‑21</p>
|
||||
<h2 className="display-md" id="about-heading">
|
||||
India's Reference for Building Material Standards
|
||||
</h2>
|
||||
<p className="body-copy">
|
||||
BIS Special Publication 21 consolidates all Indian Standards relevant to building and
|
||||
construction materials — from Portland cement to wire ropes, sanitary fittings to structural
|
||||
steels. Published by the Bureau of Indian Standards, it is the authoritative handbook used
|
||||
by architects, structural engineers, contractors, and quality inspectors across India.
|
||||
</p>
|
||||
<a
|
||||
className="btn-primary-on-dark"
|
||||
href="https://www.bis.gov.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Visit BIS Portal ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="feature-pillars" role="list">
|
||||
{PILLARS.map(({ icon, title, body }) => (
|
||||
<div className="pillar" role="listitem" key={title}>
|
||||
<span className="pillar-icon" aria-hidden="true">{icon}</span>
|
||||
<h3 className="pillar-title">{title}</h3>
|
||||
<p className="pillar-body">{body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const PILLARS = [
|
||||
{ icon: "⚡", title: "Instant Retrieval", body: "Full-text search across all 573 standards with ranked results." },
|
||||
{ icon: "📐", title: "Section-Level Detail", body: "Scope, requirements, delivery conditions — all structured fields." },
|
||||
{ icon: "🗂", title: "25 Categories", body: "Organised by BIS material sections, mirroring SP‑21's own structure." },
|
||||
{ icon: "🔒", title: "Official Source", body: "Parsed directly from the BIS SP‑21 : 2005 authoritative edition." },
|
||||
];
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M14 14l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
.recommend-page { min-height: 100vh; }
|
||||
.rec-hero { padding: 72px 0 56px; }
|
||||
|
||||
/* Search */
|
||||
.rec-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.rec-search-wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(212,83,10,0.1);
|
||||
}
|
||||
.rec-search-icon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--ink-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rec-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
color: var(--ink);
|
||||
padding: 14px 48px 14px 50px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.rec-search-input::placeholder { color: var(--body-muted); }
|
||||
.rec-search-input:disabled { opacity: 0.6; }
|
||||
.rec-clear {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--ink-48);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.rec-clear:hover { background: var(--divider); color: var(--ink); }
|
||||
|
||||
.rec-options-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rewrite-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--ink-80);
|
||||
user-select: none;
|
||||
}
|
||||
.rewrite-toggle input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rewrite-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-48);
|
||||
font-weight: 400;
|
||||
}
|
||||
.rec-submit { padding: 11px 28px; }
|
||||
|
||||
/* Example queries */
|
||||
.example-queries { margin-top: 32px; }
|
||||
.example-label { font-size: 13px; color: var(--ink-48); margin-bottom: 10px; }
|
||||
.example-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.example-chip {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-80);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
text-align: left;
|
||||
}
|
||||
.example-chip:hover { border-color: var(--accent); color: var(--accent); background: rgba(212,83,10,0.04); }
|
||||
|
||||
/* Loading */
|
||||
.results-section { padding: 48px 0 64px; min-height: 40vh; }
|
||||
.loading-state { padding: 48px 0; }
|
||||
.loading-steps { display: flex; flex-direction: column; gap: 16px; max-width: 480px; }
|
||||
.loading-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--ink-80);
|
||||
animation: fadeInUp .3s ease forwards;
|
||||
}
|
||||
.loading-step--delay { animation-delay: .6s; opacity: 0; }
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.loading-step-icon { font-size: 20px; width: 28px; text-align: center; }
|
||||
.spin-icon {
|
||||
display: inline-block;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Results header */
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.results-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.results-query { font-size: 14px; color: var(--ink-48); margin-top: 4px; }
|
||||
.results-query em { font-style: italic; color: var(--ink-80); }
|
||||
|
||||
/* Latency badges */
|
||||
.latency-badge { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.lat-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 12px;
|
||||
min-width: 72px;
|
||||
}
|
||||
.lat-badge--accent { border-color: rgba(212,83,10,0.3); background: rgba(212,83,10,0.06); }
|
||||
.lat-badge--bold { border-color: var(--ink-80); }
|
||||
.lat-ms {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
}
|
||||
.lat-badge--accent .lat-ms { color: var(--accent); }
|
||||
.lat-label { font-size: 10px; color: var(--ink-48); margin-top: 3px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* Recommend cards */
|
||||
.rec-results-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.rec-card {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto 28px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
.rec-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 24px rgba(212,83,10,0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.rec-card:active { transform: scale(.99); }
|
||||
.rec-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.rec-card-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--parchment);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rec-card-body { min-width: 0; }
|
||||
.rec-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-cat {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--chip-text);
|
||||
background: var(--chip-bg);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 3px 10px;
|
||||
}
|
||||
.card-id {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-48);
|
||||
}
|
||||
.rec-card-section {
|
||||
font-size: 12px;
|
||||
color: var(--ink-48);
|
||||
font-style: italic;
|
||||
}
|
||||
.rec-card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* AI explanation block */
|
||||
.rec-card-explanation {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: linear-gradient(135deg, rgba(212,83,10,0.04), rgba(26,34,48,0.03));
|
||||
border: 1px solid rgba(212,83,10,0.12);
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.explanation-icon { color: var(--accent); font-size: 13px; flex-shrink: 0; margin-top: 2px; }
|
||||
.explanation-text { font-size: 14px; line-height: 1.6; color: var(--ink-80); margin: 0; }
|
||||
|
||||
.card-keywords { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.keyword-chip {
|
||||
font-size: 11px;
|
||||
color: var(--ink-80);
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.rec-card-score {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.score-num {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-48);
|
||||
line-height: 1;
|
||||
}
|
||||
.score-label { font-size: 10px; color: var(--ink-48); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
.rec-card-arrow { color: var(--accent); font-size: 16px; font-weight: 600; align-self: center; }
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px 20px;
|
||||
color: #b91c1c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 833px) {
|
||||
.rec-card { grid-template-columns: 36px 1fr 28px; }
|
||||
.rec-card-score { display: none; }
|
||||
.results-header { flex-direction: column; gap: 16px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.rec-card { grid-template-columns: 1fr; gap: 12px; }
|
||||
.rec-card-rank { width: 32px; height: 32px; font-size: 15px; }
|
||||
.rec-card-arrow { display: none; }
|
||||
.rec-options-row { flex-direction: column; align-items: flex-start; }
|
||||
.rec-submit { width: 100%; justify-content: center; }
|
||||
.example-chips { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { recommend } from "../api/standards";
|
||||
import StandardModal from "../components/StandardModal";
|
||||
import "./Recommend.css";
|
||||
|
||||
export default function Recommend() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [rewrite, setRewrite] = useState(false);
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const data = await recommend({ query: q, top_n: 5, rewrite });
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
setError(err.message || "Something went wrong. Is the server running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
"Requirements for ordinary portland cement 33 grade",
|
||||
"Specifications for structural steel in buildings",
|
||||
"Standards for pipes and fittings in plumbing",
|
||||
"Timber used in construction",
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="recommend-page">
|
||||
{/* Header tile */}
|
||||
<section className="tile tile-dark rec-hero" aria-labelledby="rec-heading">
|
||||
<div className="tile-inner tile-center">
|
||||
<p className="tile-eyebrow">Hybrid Retrieval · AI Explanation</p>
|
||||
<h1 className="hero-display" id="rec-heading">Find & Understand Standards</h1>
|
||||
<p className="lead">
|
||||
Ask a natural language question — the system retrieves the most relevant
|
||||
IS standards using dense + sparse search, then explains each in plain English.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Search tile */}
|
||||
<section className="tile tile-parchment" aria-label="Recommendation search">
|
||||
<div className="tile-inner">
|
||||
<form onSubmit={handleSubmit} role="search" aria-label="Recommend standards">
|
||||
<div className="rec-search-wrap">
|
||||
<SearchIcon />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="rec-search-input"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. What standard covers tensile strength of structural steel?"
|
||||
aria-label="Search query"
|
||||
maxLength={500}
|
||||
disabled={loading}
|
||||
/>
|
||||
{query && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
className="rec-clear"
|
||||
onClick={() => { setQuery(""); setResults(null); inputRef.current?.focus(); }}
|
||||
aria-label="Clear"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rec-options-row">
|
||||
<label className="rewrite-toggle" title="Let the AI rephrase your query into precise IS keywords before searching">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rewrite}
|
||||
onChange={(e) => setRewrite(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span>Smart query rewrite</span>
|
||||
<span className="rewrite-hint">AI refines your query before searching</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn-primary rec-submit"
|
||||
type="submit"
|
||||
disabled={!query.trim() || loading}
|
||||
>
|
||||
{loading ? <><SpinIcon /> Searching…</> : "Find Standards"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Example queries */}
|
||||
{!results && !loading && (
|
||||
<div className="example-queries" aria-label="Example queries">
|
||||
<p className="example-label">Try an example:</p>
|
||||
<div className="example-chips">
|
||||
{EXAMPLE_QUERIES.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
className="example-chip"
|
||||
onClick={() => { setQuery(q); setTimeout(() => inputRef.current?.focus(), 50); }}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Results tile */}
|
||||
{(loading || results || error) && (
|
||||
<section className="tile tile-light results-section" aria-live="polite" aria-label="Results">
|
||||
<div className="tile-inner">
|
||||
{error && (
|
||||
<div className="error-banner" role="alert">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="loading-state" aria-label="Loading results">
|
||||
<div className="loading-steps">
|
||||
<LoadingStep icon="🔍" label="Running hybrid retrieval (FAISS + BM25)…" />
|
||||
<LoadingStep icon="✦" label="Generating AI explanations…" delay />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && !loading && (
|
||||
<>
|
||||
<div className="results-header">
|
||||
<div>
|
||||
<h2 className="results-title">
|
||||
{results.standards.length} Standard{results.standards.length !== 1 ? "s" : ""} Found
|
||||
</h2>
|
||||
<p className="results-query">for: <em>{results.query}</em></p>
|
||||
</div>
|
||||
<div className="latency-badge" aria-label="Timing breakdown">
|
||||
<LatencyBadge label="Retrieval" ms={results.latency.retrieval_ms} />
|
||||
<LatencyBadge label="AI" ms={results.latency.llm_ms} accent />
|
||||
<LatencyBadge label="Total" ms={results.latency.total_ms} bold />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rec-results-list" role="list">
|
||||
{results.standards.map((s, i) => (
|
||||
<RecommendCard
|
||||
key={s.standard_id}
|
||||
standard={s}
|
||||
rank={i + 1}
|
||||
onOpen={() => setSelected(standardsFullRecord(s))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<StandardModal standard={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function RecommendCard({ standard, rank, onOpen }) {
|
||||
return (
|
||||
<article
|
||||
className="rec-card"
|
||||
role="listitem"
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => e.key === "Enter" && onOpen()}
|
||||
tabIndex={0}
|
||||
aria-label={`Rank ${rank}: ${standard.standard_id}`}
|
||||
>
|
||||
<div className="rec-card-rank" aria-hidden="true">{rank}</div>
|
||||
|
||||
<div className="rec-card-body">
|
||||
<div className="rec-card-meta">
|
||||
<span className="card-cat">{standard.category}</span>
|
||||
<span className="card-id">{standard.standard_id}</span>
|
||||
{standard.matched_section && (
|
||||
<span className="rec-card-section">§ {standard.matched_section}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="rec-card-title">{standard.title}</h3>
|
||||
|
||||
{standard.explanation && (
|
||||
<div className="rec-card-explanation" aria-label="AI explanation">
|
||||
<span className="explanation-icon" aria-hidden="true">✦</span>
|
||||
<p className="explanation-text">{standard.explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{standard.keywords?.length > 0 && (
|
||||
<div className="card-keywords" aria-label="Keywords">
|
||||
{standard.keywords.slice(0, 5).map((kw) => (
|
||||
<span className="keyword-chip" key={kw}>{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rec-card-score" aria-label={`Relevance score ${standard.score}`}>
|
||||
<span className="score-num">{(standard.score * 100).toFixed(0)}</span>
|
||||
<span className="score-label">score</span>
|
||||
</div>
|
||||
|
||||
<span className="rec-card-arrow" aria-hidden="true">→</span>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyBadge({ label, ms, accent, bold }) {
|
||||
return (
|
||||
<div className={`lat-badge${accent ? " lat-badge--accent" : ""}${bold ? " lat-badge--bold" : ""}`}>
|
||||
<span className="lat-ms">{ms}ms</span>
|
||||
<span className="lat-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingStep({ icon, label, delay }) {
|
||||
return (
|
||||
<div className={`loading-step${delay ? " loading-step--delay" : ""}`}>
|
||||
<span className="loading-step-icon" aria-hidden="true">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="rec-search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M14 14l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinIcon() {
|
||||
return <span className="spin-icon" aria-hidden="true">⟳</span>;
|
||||
}
|
||||
|
||||
// Merge recommendation result with full standard record for the modal
|
||||
function standardsFullRecord(s) {
|
||||
return {
|
||||
standard_id: s.standard_id,
|
||||
title: s.title,
|
||||
category: s.category,
|
||||
summary: s.explanation || "",
|
||||
keywords: s.keywords || [],
|
||||
key_sections: {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.standards-page { min-height: 100vh; }
|
||||
.search-tile { padding: 48px 0 40px; }
|
||||
.results-tile { padding: 32px 0 64px; min-height: 60vh; }
|
||||
|
||||
.search-form { max-width: 700px; }
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--ink-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
font-family: var(--font-text);
|
||||
font-size: 17px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 13px 48px 13px 44px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(212,83,10,0.1);
|
||||
}
|
||||
.search-input::placeholder { color: var(--body-muted); }
|
||||
.search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--ink-48);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.search-clear:hover { background: var(--divider); color: var(--ink); }
|
||||
|
||||
.filter-row { margin-top: 12px; }
|
||||
.category-filter {
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
border: 1.5px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 240px;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.category-filter:focus { border-color: var(--accent); }
|
||||
|
||||
.results-meta {
|
||||
font-size: 13px;
|
||||
color: var(--ink-48);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.results-empty {
|
||||
text-align: center;
|
||||
padding: 64px 0;
|
||||
color: var(--ink-48);
|
||||
}
|
||||
.empty-title { font-size: 17px; font-weight: 600; color: var(--ink-80); margin-bottom: 6px; }
|
||||
.empty-sub { font-size: 14px; }
|
||||
|
||||
/* Skeleton */
|
||||
.results-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 180px;
|
||||
background: linear-gradient(90deg, var(--parchment) 25%, var(--divider) 50%, var(--parchment) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--divider);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
.page-numbers { display: flex; align-items: center; gap: 4px; }
|
||||
.page-btn {
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--ink-80);
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-btn:hover:not(:disabled):not(.active) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.page-ellipsis { font-size: 14px; color: var(--ink-48); padding: 0 4px; }
|
||||
|
||||
.error-banner {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--r-md);
|
||||
padding: 16px 20px;
|
||||
color: #b91c1c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-form { max-width: 100%; }
|
||||
.category-filter { min-width: 100%; width: 100%; }
|
||||
.pagination { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { fetchStandards, fetchCategories } from "../api/standards";
|
||||
import { useDebounce } from "../hooks/useDebounce";
|
||||
import StandardCard from "../components/StandardCard";
|
||||
import StandardModal from "../components/StandardModal";
|
||||
import "./Standards.css";
|
||||
|
||||
const PAGE_SIZE = 18;
|
||||
|
||||
export default function Standards() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||
const [category, setCategory] = useState(searchParams.get("category") || "");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [results, setResults] = useState([]);
|
||||
const [meta, setMeta] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (q, cat, pg) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchStandards({ q, category: cat, page: pg, limit: PAGE_SIZE });
|
||||
setResults(data.data);
|
||||
setMeta(data.meta);
|
||||
} catch {
|
||||
setError("Could not load standards. Is the server running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
const params = {};
|
||||
if (debouncedQuery) params.q = debouncedQuery;
|
||||
if (category) params.category = category;
|
||||
setSearchParams(params, { replace: true });
|
||||
load(debouncedQuery, category, 1);
|
||||
}, [debouncedQuery, category, load, setSearchParams]);
|
||||
|
||||
const handlePageChange = (pg) => {
|
||||
setPage(pg);
|
||||
load(debouncedQuery, category, pg);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="standards-page">
|
||||
<section className="tile tile-parchment search-tile" aria-labelledby="search-heading">
|
||||
<div className="tile-inner">
|
||||
<h1 className="display-lg" id="search-heading">Find an IS Standard</h1>
|
||||
<p className="lead-sub">Search by standard number, title, material, or keyword.</p>
|
||||
|
||||
<form
|
||||
className="search-form"
|
||||
role="search"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="search-wrap">
|
||||
<SearchIcon />
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. Ordinary Portland Cement, IS 269, aggregates…"
|
||||
aria-label="Search standards"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="search-clear"
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-row">
|
||||
<select
|
||||
className="category-filter"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.name} value={c.name}>
|
||||
{c.name} ({c.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tile tile-light results-tile" aria-live="polite" aria-atomic="false">
|
||||
<div className="tile-inner">
|
||||
{error && <div className="error-banner" role="alert">{error}</div>}
|
||||
|
||||
{!error && meta && (
|
||||
<p className="results-meta">
|
||||
{loading ? "Searching…" : `${meta.total} standard${meta.total !== 1 ? "s" : ""} found`}
|
||||
{meta.total > 0 && ` — page ${meta.page} of ${meta.totalPages}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && results.length === 0 && (
|
||||
<div className="results-skeleton">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div className="skeleton-card" key={i} aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length === 0 && !error && (
|
||||
<div className="results-empty">
|
||||
<p className="empty-title">No standards found</p>
|
||||
<p className="empty-sub">Try a different keyword or clear the category filter.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="results-grid">
|
||||
{results.map((s) => (
|
||||
<StandardCard
|
||||
key={s.standard_id}
|
||||
standard={s}
|
||||
onClick={() => setSelected(s)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta && meta.totalPages > 1 && (
|
||||
<nav className="pagination" aria-label="Results pagination">
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<div className="page-numbers">
|
||||
{buildPageRange(page, meta.totalPages).map((p, i) =>
|
||||
p === "…" ? (
|
||||
<span key={`ellipsis-${i}`} className="page-ellipsis">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`page-btn${p === page ? " active" : ""}`}
|
||||
onClick={() => handlePageChange(p)}
|
||||
aria-label={`Page ${p}`}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={page >= meta.totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected && (
|
||||
<StandardModal standard={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageRange(current, total) {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages = new Set([1, total, current, current - 1, current + 1].filter(p => p >= 1 && p <= total));
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
const result = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push("…");
|
||||
result.push(sorted[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M14 14l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user