{standard.standard_id}
+{standard.title}
+ {standard.summary && ( +{standard.summary}
+ )} + {standard.keywords?.length > 0 && ( +From a5cf7bbfdab91cc056b62b0fbf96a9de646f4323 Mon Sep 17 00:00:00 2001 From: Kshitij <160704796+kshitij-ka@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:56:23 +0530 Subject: [PATCH] feat: add web client frontend with monorepo config. --- web/client/.gitignore | 24 + web/client/README.md | 16 + web/client/eslint.config.js | 21 + web/client/index.html | 13 + web/client/package-lock.json | 2516 +++++++++++++++++++ web/client/package.json | 28 + web/client/public/favicon.svg | 1 + web/client/public/icons.svg | 24 + web/client/src/App.css | 1 + web/client/src/App.jsx | 25 + web/client/src/api/standards.js | 77 + web/client/src/assets/hero.png | Bin 0 -> 13057 bytes web/client/src/assets/react.svg | 1 + web/client/src/assets/vite.svg | 1 + web/client/src/components/Footer.css | 52 + web/client/src/components/Footer.jsx | 43 + web/client/src/components/Navbar.css | 99 + web/client/src/components/Navbar.jsx | 98 + web/client/src/components/StandardCard.css | 82 + web/client/src/components/StandardCard.jsx | 36 + web/client/src/components/StandardModal.css | 297 +++ web/client/src/components/StandardModal.jsx | 215 ++ web/client/src/hooks/useDebounce.js | 10 + web/client/src/index.css | 254 ++ web/client/src/main.jsx | 13 + web/client/src/pages/About.css | 72 + web/client/src/pages/About.jsx | 85 + web/client/src/pages/Categories.css | 83 + web/client/src/pages/Categories.jsx | 91 + web/client/src/pages/Home.css | 105 + web/client/src/pages/Home.jsx | 150 ++ web/client/src/pages/Recommend.css | 307 +++ web/client/src/pages/Recommend.jsx | 273 ++ web/client/src/pages/Standards.css | 154 ++ web/client/src/pages/Standards.jsx | 216 ++ web/client/vite.config.js | 12 + web/package.json | 10 + 37 files changed, 5505 insertions(+) create mode 100644 web/client/.gitignore create mode 100644 web/client/README.md create mode 100644 web/client/eslint.config.js create mode 100644 web/client/index.html create mode 100644 web/client/package-lock.json create mode 100644 web/client/package.json create mode 100644 web/client/public/favicon.svg create mode 100644 web/client/public/icons.svg create mode 100644 web/client/src/App.css create mode 100644 web/client/src/App.jsx create mode 100644 web/client/src/api/standards.js create mode 100644 web/client/src/assets/hero.png create mode 100644 web/client/src/assets/react.svg create mode 100644 web/client/src/assets/vite.svg create mode 100644 web/client/src/components/Footer.css create mode 100644 web/client/src/components/Footer.jsx create mode 100644 web/client/src/components/Navbar.css create mode 100644 web/client/src/components/Navbar.jsx create mode 100644 web/client/src/components/StandardCard.css create mode 100644 web/client/src/components/StandardCard.jsx create mode 100644 web/client/src/components/StandardModal.css create mode 100644 web/client/src/components/StandardModal.jsx create mode 100644 web/client/src/hooks/useDebounce.js create mode 100644 web/client/src/index.css create mode 100644 web/client/src/main.jsx create mode 100644 web/client/src/pages/About.css create mode 100644 web/client/src/pages/About.jsx create mode 100644 web/client/src/pages/Categories.css create mode 100644 web/client/src/pages/Categories.jsx create mode 100644 web/client/src/pages/Home.css create mode 100644 web/client/src/pages/Home.jsx create mode 100644 web/client/src/pages/Recommend.css create mode 100644 web/client/src/pages/Recommend.jsx create mode 100644 web/client/src/pages/Standards.css create mode 100644 web/client/src/pages/Standards.jsx create mode 100644 web/client/vite.config.js create mode 100644 web/package.json diff --git a/web/client/.gitignore b/web/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/client/README.md b/web/client/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/web/client/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/web/client/eslint.config.js b/web/client/eslint.config.js new file mode 100644 index 0000000..ea36dd3 --- /dev/null +++ b/web/client/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/web/client/index.html b/web/client/index.html new file mode 100644 index 0000000..6e56e6e --- /dev/null +++ b/web/client/index.html @@ -0,0 +1,13 @@ + + +
+ + + +mI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8W L0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e +bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKc w$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(% KtuV^zC& Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5M o-0$pkyV3VV4B@Qms46M zuBxGRV@HxU 7Wwx-6CB zaU*HO <_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#< Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XC lkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn= Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>` zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1 R$C6ja5!^ZGh;YRhhxs58qJWo9@Bc eac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=R NC6n E=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-Nue qTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<1 1$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdN K8ZDKZ?QFLU? zh30 G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsb ob0{xu@0TB_*>G7w0ICn zr#V oBktqHZ~XxhiKD*lcG|b;H *|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn &E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fR Za#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG ;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9 & zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g( lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!w UA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?u f$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI )-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr> )f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p = z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z> vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=G p_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQ A zvqp;$kmGJY>lL sN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpY R}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37 fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+Pm NABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_ ^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx| $S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6 }_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh +g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt !MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLj lm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?& Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t `F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/web/client/src/assets/react.svg b/web/client/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/web/client/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/client/src/assets/vite.svg b/web/client/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/client/src/assets/vite.svg @@ -0,0 +1 @@ + diff --git a/web/client/src/components/Footer.css b/web/client/src/components/Footer.css new file mode 100644 index 0000000..ba8b477 --- /dev/null +++ b/web/client/src/components/Footer.css @@ -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; } +} diff --git a/web/client/src/components/Footer.jsx b/web/client/src/components/Footer.jsx new file mode 100644 index 0000000..48fb5b2 --- /dev/null +++ b/web/client/src/components/Footer.jsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; +import "./Footer.css"; + +export default function Footer() { + return ( + + ); +} diff --git a/web/client/src/components/Navbar.css b/web/client/src/components/Navbar.css new file mode 100644 index 0000000..908b287 --- /dev/null +++ b/web/client/src/components/Navbar.css @@ -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; } +} diff --git a/web/client/src/components/Navbar.jsx b/web/client/src/components/Navbar.jsx new file mode 100644 index 0000000..19d0338 --- /dev/null +++ b/web/client/src/components/Navbar.jsx @@ -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 ( + <> + + + {open && ( + + )} + > + ); +} + +function BISIcon() { + return ( + + ); +} diff --git a/web/client/src/components/StandardCard.css b/web/client/src/components/StandardCard.css new file mode 100644 index 0000000..0c533ca --- /dev/null +++ b/web/client/src/components/StandardCard.css @@ -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; } diff --git a/web/client/src/components/StandardCard.jsx b/web/client/src/components/StandardCard.jsx new file mode 100644 index 0000000..8cbed14 --- /dev/null +++ b/web/client/src/components/StandardCard.jsx @@ -0,0 +1,36 @@ +import "./StandardCard.css"; + +export default function StandardCard({ standard, onClick }) { + const sectionCount = Object.keys(standard.key_sections || {}).length; + + return ( + e.key === "Enter" && onClick()} + tabIndex={0} + role="button" + aria-label={`View details for ${standard.standard_id}`} + > + {standard.category} + + ); +} diff --git a/web/client/src/components/StandardModal.css b/web/client/src/components/StandardModal.css new file mode 100644 index 0000000..4571a2c --- /dev/null +++ b/web/client/src/components/StandardModal.css @@ -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; } +} diff --git a/web/client/src/components/StandardModal.jsx b/web/client/src/components/StandardModal.jsx new file mode 100644 index 0000000..ff32cf9 --- /dev/null +++ b/web/client/src/components/StandardModal.jsx @@ -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 ( +{standard.standard_id}
+{standard.title}
+ {standard.summary && ( +{standard.summary}
+ )} + {standard.keywords?.length > 0 && ( ++ {standard.keywords.slice(0, 5).map((kw) => ( + {kw} + ))} ++ )} ++ + {sectionCount} section{sectionCount !== 1 ? "s" : ""} + + ++++ ); +} + +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); +} diff --git a/web/client/src/hooks/useDebounce.js b/web/client/src/hooks/useDebounce.js new file mode 100644 index 0000000..34ee605 --- /dev/null +++ b/web/client/src/hooks/useDebounce.js @@ -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; +} diff --git a/web/client/src/index.css b/web/client/src/index.css new file mode 100644 index 0000000..3d6ee52 --- /dev/null +++ b/web/client/src/index.css @@ -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; } +} diff --git a/web/client/src/main.jsx b/web/client/src/main.jsx new file mode 100644 index 0000000..7aed83e --- /dev/null +++ b/web/client/src/main.jsx @@ -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( ++ {/* Header */} ++++ + {/* Standard detail */} ++ {standard.category} ++ +{standard.title}
+ {standard.standard_id} ++ {standard.summary && ( ++++ )} + + {standard.keywords?.length > 0 && ( +Summary
+{standard.summary}
+++ )} + + {sections.length > 0 && ( +Keywords
++ {standard.keywords.map((kw) => ( + {kw} + ))} ++++ )} + + {/* AI chat panel */} +Key Sections
++ {sections.map(([name, text]) => ( ++++ ))} +{name}
+{text}
++++ + Ask AI about this standard +
+ + {messages.length > 0 && ( ++ {messages.map((m, i) => ( ++ )} + + {messages.length === 0 && !asking && ( ++ {m.role === "ai" && ( + ✦ + )} ++ ))} + {asking && ( +{m.text}
++ + + + ++ )} + {aiError && ( +{aiError}
+ )} + ++ {getSuggestions(standard).map((s) => ( + + ))} ++ )} + + ++ +); diff --git a/web/client/src/pages/About.css b/web/client/src/pages/About.css new file mode 100644 index 0000000..8555e4a --- /dev/null +++ b/web/client/src/pages/About.css @@ -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; } +} diff --git a/web/client/src/pages/About.jsx b/web/client/src/pages/About.jsx new file mode 100644 index 0000000..7787419 --- /dev/null +++ b/web/client/src/pages/About.jsx @@ -0,0 +1,85 @@ +import "./About.css"; + +export default function About() { + return ( ++ ++ + + ); +} diff --git a/web/client/src/pages/Categories.css b/web/client/src/pages/Categories.css new file mode 100644 index 0000000..b23512f --- /dev/null +++ b/web/client/src/pages/Categories.css @@ -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; } +} diff --git a/web/client/src/pages/Categories.jsx b/web/client/src/pages/Categories.jsx new file mode 100644 index 0000000..81361f4 --- /dev/null +++ b/web/client/src/pages/Categories.jsx @@ -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 ( ++ + +++Bureau of Indian Standards
+About BIS SP‑21
++ India's authoritative handbook on building and construction material standards. +
++ +++++ + +What is SP‑21?
++ BIS Special Publication 21 — Handbook on Building Materials — 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. +
++ 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. +
+ +Who uses it?
++ 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. +
+ +About this portal
++ 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. +
++ 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. +
++ + ); +} diff --git a/web/client/src/pages/Home.css b/web/client/src/pages/Home.css new file mode 100644 index 0000000..1f41a49 --- /dev/null +++ b/web/client/src/pages/Home.css @@ -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; } +} diff --git a/web/client/src/pages/Home.jsx b/web/client/src/pages/Home.jsx new file mode 100644 index 0000000..3369275 --- /dev/null +++ b/web/client/src/pages/Home.jsx @@ -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 ( ++ + +++SP‑21 : 2005
+Material Categories
++ {total} standards across {categories.length} building material sections. +
++ ++ {loading ? ( +++ {Array.from({ length: 12 }).map((_, i) => ( + + ))} ++ ) : ( ++ {categories.map((cat) => ( + + ))} ++ )} ++ {/* Hero */} + + ); +} + +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 ( + + ); +} diff --git a/web/client/src/pages/Recommend.css b/web/client/src/pages/Recommend.css new file mode 100644 index 0000000..b59be66 --- /dev/null +++ b/web/client/src/pages/Recommend.css @@ -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; } +} diff --git a/web/client/src/pages/Recommend.jsx b/web/client/src/pages/Recommend.jsx new file mode 100644 index 0000000..0db872b --- /dev/null +++ b/web/client/src/pages/Recommend.jsx @@ -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 ( ++ + + {/* Categories */} +++Special Publication 21 · 2005
++ Handbook of
+
Building Materials ++ Indian Standards across 25 material categories —
+ + + + {stats && ( +
+ searchable, categorised, and ready to reference. +++ )} ++ {stats.totalStandards} + IS Standards ++ ++ {stats.totalCategories} + Categories ++ ++ 929 + Pages Indexed +++ + + {/* About strip */} +++++25 Material Categories
+Every building material section from SP‑21, indexed and searchable.
++ {categories.map((cat) => ( + + ))} +++ +++++++About SP‑21
++ India's Reference for Building Material Standards +
++ 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. +
+ + Visit BIS Portal ↗ + ++ {PILLARS.map(({ icon, title, body }) => ( +++ ++ ))} +{title}
+{body}
++ {/* Header tile */} + + ); +} + +// ── Sub-components ────────────────────────────────────────────────────────── + +function RecommendCard({ standard, rank, onOpen }) { + return ( ++ + + {/* Search tile */} +++Hybrid Retrieval · AI Explanation
+Find & Understand Standards
++ Ask a natural language question — the system retrieves the most relevant + IS standards using dense + sparse search, then explains each in plain English. +
++ + + {/* Results tile */} + {(loading || results || error) && ( ++ + + {/* Example queries */} + {!results && !loading && ( ++++ )} +Try an example:
++ {EXAMPLE_QUERIES.map((q) => ( + + ))} +++ + )} + + {selected && ( ++ {error && ( +++ Error: {error} ++ )} + + {loading && ( +++ )} + + {results && !loading && ( + <> ++++ + ++ ++++ {results.standards.length} Standard{results.standards.length !== 1 ? "s" : ""} Found +
+for: {results.query}
++++ + + + {results.standards.map((s, i) => ( ++ > + )} +setSelected(standardsFullRecord(s))} + /> + ))} + setSelected(null)} /> + )} + e.key === "Enter" && onOpen()} + tabIndex={0} + aria-label={`Rank ${rank}: ${standard.standard_id}`} + > + + + + ); +} + +function LatencyBadge({ label, ms, accent, bold }) { + return ( +++ ++ {standard.category} + {standard.standard_id} + {standard.matched_section && ( + § {standard.matched_section} + )} ++ +{standard.title}
+ + {standard.explanation && ( ++ ++ )} + + {standard.keywords?.length > 0 && ( +{standard.explanation}
++ {standard.keywords.slice(0, 5).map((kw) => ( + {kw} + ))} ++ )} ++ {(standard.score * 100).toFixed(0)} + score ++ + ++ {ms}ms + {label} ++ ); +} + +function LoadingStep({ icon, label, delay }) { + return ( ++ + {label} ++ ); +} + +function SearchIcon() { + return ( + + ); +} + +function SpinIcon() { + return ; +} + +// 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: {}, + }; +} diff --git a/web/client/src/pages/Standards.css b/web/client/src/pages/Standards.css new file mode 100644 index 0000000..badb0ef --- /dev/null +++ b/web/client/src/pages/Standards.css @@ -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; } +} diff --git a/web/client/src/pages/Standards.jsx b/web/client/src/pages/Standards.jsx new file mode 100644 index 0000000..86cf1f2 --- /dev/null +++ b/web/client/src/pages/Standards.jsx @@ -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 ( ++ + ); +} + +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 ( + + ); +} diff --git a/web/client/vite.config.js b/web/client/vite.config.js new file mode 100644 index 0000000..fd16461 --- /dev/null +++ b/web/client/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:5000", + }, + }, +}) diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..cd305d7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,10 @@ +{ + "name": "bis-sp21-web", + "version": "1.0.0", + "private": true, + "scripts": { + "server": "node server/index.js", + "client": "npm --prefix client run dev", + "dev": "start cmd /k npm run server && npm run client" + } +}+ + +++Find an IS Standard
+Search by standard number, title, material, or keyword.
+ + ++ + + {selected && ( ++ {error &&+{error}} + + {!error && meta && ( ++ {loading ? "Searching…" : `${meta.total} standard${meta.total !== 1 ? "s" : ""} found`} + {meta.total > 0 && ` — page ${meta.page} of ${meta.totalPages}`} +
+ )} + + {loading && results.length === 0 && ( ++ {Array.from({ length: 6 }).map((_, i) => ( + + ))} ++ )} + + {!loading && results.length === 0 && !error && ( +++ )} + + {results.length > 0 && ( +No standards found
+Try a different keyword or clear the category filter.
++ {results.map((s) => ( ++ )} + + {meta && meta.totalPages > 1 && ( + + )} +setSelected(s)} + /> + ))} + setSelected(null)} /> + )} +