feat: add web client frontend with monorepo config.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user