From 0b081b646cf722585a1eee281ceaa58aaed9abd2 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Tue, 13 Feb 2024 12:41:19 +0100 Subject: [PATCH 01/17] feat/fe-react structure + tailwind --- .gitignore | 1 + backend/bun.lockb | Bin 30352 -> 30352 bytes frontend/.eslintrc.cjs | 18 ++++++++ frontend/.gitignore | 24 +++++++++++ frontend/README.md | 30 +++++++++++++ frontend/bun.lockb | Bin 0 -> 111077 bytes frontend/components.json | 17 ++++++++ frontend/index.html | 13 ++++++ frontend/package.json | 37 ++++++++++++++++ frontend/postcss.config.js | 6 +++ frontend/public/vite.svg | 1 + frontend/src/App.tsx | 5 +++ frontend/src/assets/react.svg | 1 + frontend/src/index.css | 60 ++++++++++++++++++++++++++ frontend/src/lib/utils.ts | 6 +++ frontend/src/main.tsx | 10 +++++ frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 77 ++++++++++++++++++++++++++++++++++ frontend/tsconfig.json | 29 +++++++++++++ frontend/tsconfig.node.json | 11 +++++ frontend/vite.config.ts | 12 ++++++ 21 files changed, 359 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100755 frontend/bun.lockb create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf70988 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/node_modules diff --git a/backend/bun.lockb b/backend/bun.lockb index 193c880e0ebb35ad7d457f9fdc41447825e7ca65..6e4dc88c4baea8aa0a219b5fbdae54f6a4cc20f6 100755 GIT binary patch delta 110 zcmbR6mT|&c#tjFoCeLBDnEb;^WHM`6_v8hvf|D<>vP`~WEi#$CtaGwJSpyy%JXwKD mWU_&S#AE?S9jH2w$sep(Haj?W3bQd<=$YynY(5`yP!Rx%`zd1p delta 1416 zcmbR6mT|&c#tjFo*aA;f*9(PBp2#XT*~U?Xk#X`zE9v^;)ZDVvA_j&gMg|6628IR( zAfTgIJzk5MfQ~81%*zKkDk(EhFRK{DqNZ3qwGAZEyn34Yj<93uX%no3Er!|)34hwg zF+RuC(=u`4vlwbGF#c(fs)?`|YA-na2QK*Y>S>s_i3tTA+`-QR(a!*)A*pq;o`ck6 a69*9@u)}5>$6jGJPyxoku=#w*K}7%>xFduB diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.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/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0d6babe --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + 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/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..81b36157bdca6e901d18396e348ee1997a20ae56 GIT binary patch literal 111077 zcmeFa2{@Hq`#!vB%aox^A(@hS3`JzjoLP~~^DILlBq6gDkxEfiNXghpWR|E%BBhck zN)jplYq8h!yMOQTzE9iZ`@ZA*zT>@)=kDBVIM3@^C?|OK-#RL8Q#O>X@#httl zaoGoYQ-Mp=)5pQX+0D~Q)Y-?+%Pv6lkOUPe4u^aGKGGp+%lgT~{qcbN~g&aX4ClX9)R^K$;$;%K$>% z8=y(Begr@&faV|paSuqZ12_WGECAmCgz>E>;4@GO^0`4Ktp6t+#J2#VFdn261LA`5 zga9$9??b@Du5J#lpq+01PC-rpNdO79=LJCM*Vitfs6e%Nv*V?EUN5y}xgt2+29Qnx2-|lAAe;wognTnXT9r^PLCD`k$X^Q(Os$A1;bq=y zfN&ku1BCgv0YcquLU|%U=r4$nwg(97832U!%Y{Zj<=g|`y0a|3kqatpwnQdlmh0y=Ph@+vOJ6FznizSr<;pwz!Ojp`U6|rAz05( zX*uoeX%`USUhW4c|G)q@5U-Q}!9XX!VBBTpWxr1TzJ5-?!!W1^$Bjc}xjl9c zK7O#H`#?VICw0~3{3eiQ1ZhVfhX6k}Zzo45`#`wjy!^o*z`zRh^Z3J(|P=O*e7>iPzH2RryUI{B$D>zM-tTT4U<(1-KxG(b52g8;&Laav<}{(FNo z9CvGguz!weF2@}R5XLF4wTwIf;re(05RTVwP#?|@et=Ln3B(A~#{fcJo6d4SJpu^( zvm7AIKTkj=z2*50Hgh{qJ4Yu9kcRrxy36sq0Ufw6Jw}lJ0+0&e2Y@gxLqhy}O_ui!Hh_>X1rTfv5n-mw z_57W@4mtS&Uu&1+3bym~bn_2bnrHT4KKXmv`NR7r*i)A__$_;v>)kY4?uVbo%lnlC zsKW(xz4tBqnFdG$(t`luIyM3b+XLz>jSGl*DNb;&3kddg!r56Y=Nkfq`!yJorG9qZ zzg*9=)Bc8=;;X`gWWl<2U%Rt)W}7|9CNG;-)caDO+r#8HYUFUlFhL;v=)_pCf)4Y= zXT~q0znTZCk^OM|F#0}DV(MGb$TgcsNqI9tjwZAXG{$k=#hcA_s6t+I(ziK?20QOd znqafg-yKbVz|%8|YrzXX+b*%IWEY$oV`h35ZT5V8ONbA>1qaVN-Wp1tJNrq_zt;G1 z(f79}$t_M$uiFghwO33{bv0NHR}4}wYjVK&E^(MHtn{J`SZzi9etDS1)uKD^PaR%6V^K($zl4R5{8PecvPaYkALWrTgJ? z>zzCnq@SF%)pnrfi8JecxA3G`K$<2~Kr-(s`*7foUp3q3TAhp>kKb~7DkNuc=d$~u zz~BW-4lShw$~c|39p}!rupRWOqJCU_Sak!5`jOmi-TD{v=C+$?y_&*@%bc|+p=?uFHO%9=8wp$7wB;toP^KQv#Bg$GAh*NxT3b&#BI7mGZn`L$@Aha z@e1kZ?uz$we_*9E(h$1V>YQR?p7)s7<8$Vpj9SfX#}|K|w4146>{)xa?h~itx0;d` zdKp)|xs>UXwFUJ;;#$9EEg7ef-?;PL`|x|$)f|p;9uhMMl%cxtt~ZMO{kH6wdurEy z)8cJeSZ zpAmzsDhb|vw< zRVO1^q9pXWYySJiDuJK2Kg((9t1}Kks^EC1x= z87fl3!`I^Jy1Uo<8o$r?NVC7W_c3n7WGcx;*yN4nd&~X^>W!A9kNOs--BDTq??$gl z`7_AS^R*UTo=R%0-#PyNSZx}*!A?n$U#-75LJUYghIU0LksCSt#Wp)`=fAN-YQ95b zNQIB$yPEprgp}XsD|y$C?)Y-VfR_G5XDJ`vN@6&gVQs)ON6+?eq()g26Uvu57s?mM zr>NLk`0g<>Z?H-^8@bDx^!|%UiUE3FMYY4^-zsJG+A4BRm4#5O-z8i{lGfHV6TumJ z#q9K>kEz!fSTui}-Z{?eo|F|Ei!XAuSuY>-=}f>>j?&CMd*)x1bexPcp0>G*-Klp} zgf<9lBT0Cl)0soh@mOHQpislj%1?>^eD3vd_X!HVXRnVtYe=5yedKmW;hOYXuc2Dp zgyz-}Qe_$z^^xr2LyZ!uSvHkx=}5ELCGI{SaoE4{=TAxF7Fj3bT^+)kIM!cnA~(#? z81|}t^QQVjq0|Gpi+m9x!qHClKTl1cx1=B1eaAH7$W8idvu3Z}32)%k4dFVZbIXrW zrzYsm_w8iz+J)OF`1WY)S;p@;Bbvrtx1qVT`RFNCmmlUnS7ZT`nRerJLy5Tl=Pg zF`;+RtgSvzoQ}etGqrSd)VQI!$0^_av4zABs>F&Ev4>S+s!{^a9&On9-A3yj=lA}t9y-F_3h}(8ej4%kVglfi1}`1nE2bjMM(VWu9~4}`!Ffb(r+zK zU0oUlZ6Xf8bEdmK8{+vrS=R>b*Y&NV<>5AZ2b*rGp3Mxhqk4S)RXcy^q51V$qbwT- zUbryjiPVk_cDx|_lrHO?S-fsxm}mM&0Ml77!OivsY4!%4mj+%GS8x`vHlDi+5KIZgd|0{E}7XO+_J{gK^%a@mc$R8wLvLLT>ZipV5n3#@2DM zQb+Phy^DPvP@>;L;eX$(u$YwY>)wEgV2eejx*5@15r#wkhr^`AIBOZ!@MzXpXB=S? z_S<&KA>`48OJP2lPwB#A^)d`6bFS`h3O(@4v+nmE%OIP|jrDyU9Sa}EcG}rBOX*Xx ztuIWD9X;+7eW1zqm>$(=*}3YMeM6VCy!2()Mf&fFnsj676IWqM3c;jpt8Wv%FWMu^GIlu08pxt#4JZPU-k{xpZG+)~SncWQ>BX z*e)n&6f9ofRH91f{7UJzndpv>qTsq6OcnRX^El`GTk z!%W&A_tKo&@*dsqr!sDJvxRa`CHL4KDBZqwjCseZ!-4}IxM&{QvB3%@C>&YUi8T~|72vA^|FHj{@0I%h35ej)X{r5C z6Y3CS$iFEW4krcp;F)Ks{Sb*U#D4<#;8A$VKjf}7{;gopz^j4?H1;cv-x~12D}bf= zQA{g||199k13r9Cf=28ZB42}muL1Zdz0&r}f`+RDKH?#r3@$0W2BH|C11^_maRzyrm!szToG*fDiW{)c>gbPl(!| zM&QHvR}ur_4*0f?LT2K>EQT59=u>2#2E7D0{C$L!}b&F8|ou|4#7X1fB%Hv zPvHO4`Ns-gGAV=j;rxUCQC#5T-x{jt0{C$J;r<2lQ2yVNG4=8QUmfsaePX%Tx+4Vt zu>FuvY#$?TE1gaMb`0)7wa$(zvy^kRNDBwF2_X`mVy2Sb?2VWq-_7l5qkS_AG6Y!ybxc@_2Y5yGsd^rE$yo2k97m5&J z$bTK+s{%gqi}L>@f$EVl{qOnrCmt$Cd;`Eo{RjJSrStbV;Om3mRNgVtqpi#1~{)-S|5JKDPe};r$Vi|8&4NB=jH3`y&|>|3|=w=bwK%e!SqzO!Zap zT>u|D{^&XCPi;VLEdqS(^DD7qi1hOQ zNAVNe2E_ja_;CKC`wzD=B;%8o&tQ>|4478@pl5g7$JVx{+0S)1bhWd z`%xUQjelpT-p`v@K-bcn1q(!fBn<-;|ch1|3m8*-ADhl5YhU(4fyc> z4eNtfY4EqSL>c0ji7cOAA?cs+=Kx=G75HlKDU(NV`Tcv*m z@bY9e{TB!LtLeWEz}H>H__Ir`uKyhYe>LqdS*8D3-0BA zGPM7n0DNmg|HFG2)FH+Ye+uy7`4PUi0rw%I4e?I`GK?P*Q64cOsBRmEPYH;y4~Q|u z-wj^=XaWByP3$`aR6hyu!4iysK4Jf%`#4Phogw}Mz(?(ed}0mp=Lmf07v`w*M&LgAgKM+|W0%Z9wst1HJ~}uY?PIA^tqzqxYYv4`BJ94Dn^aq=owz)d3%^TZGWZ-x=adgTaUIZ(v>M8@7WOLwrBLhws19{<+fl z^8p{uzm@og@ge_j0U7om4anO_OyGn%h|dik9^mr>%w_}^%7^K{GgR*YAj9z|cHc*u zh<^?6VgD1`@BhjlBJhdbcm9>X1uQ<;fB!W81Aq_P59b}RV-I6M?Y{u{*!hRz`x7Gm zYru#1AJl$gV?caS#pV46#*gy;-Id13tj6aEdrR|o#l`28n;|C*2de+7Klf5f&M=^(x!a1LG}E!}_NyVI5KzcGLh z*B|r`FW3jf81jD?@X`Ha4Ir+RKLz-3{z0Gr#J}op@NW(QK8%N0-_RF|KO6830DmQ8 zkL7;`d@}+c<`WwO@~;Xue3jQ5xZ&B>t)g9v;Cfh=2V4Lk94b z0UzqYw!<+b#*qIsz}E+SG*4sXi9Ke2HmeZ)Tk_^N=9;zo$- z{U=2H2Ed2=H){WXs{gO~h!0=hg8!D{|0mynY6G${fxnVIK>jNLe?Ngw?A`<0g82J& zmirIJ4*jo(g+v(QzXp7;1R@{>_C3mn>Ay2nk6ZVDp8wz&kpCSCQ-=69fDfkNU*lie z+)UIIK>U2bS0VHtGteh?4kNy>-v8cz;Bz=JhWK%SkM7_9r2ifPz8Z)h>8*7C!h@IB zaQ;ExaQx39p+7VNRoe^r@cse&?VrrQn}84ZKja@GF^2sA1e@2sRq&0#&1W_IYk-gL zU$FmH8vhSKM)$9O!q)?xZ?%f{UkCiv_+J2glU4XP0ykfH|NW=qp9{#V@&6t0!4~k3 z{VRjT170CVz&Q)s0H0%sF*JV<13r5H2Kg)9e{KUleE$L09uqN+33ZVFXwZ44tKh!^ ze7OJrQ~#X6x%Ddeae%K3_^|I_n;;TnsQul5FAezczVlD^FDkHj;Q3)CTo@nnuMYU| z{RLe6D>1|m0el(2rv_wV+YbF8{$0SA0(@fU9f}e0#{nPRzhD`$F(AI^-v4=iM%u(6 z;=2Gocm);#)5Q9Q`iP$g_~5^PJb&j`$v+QnKC4+jB<9Qe2b{lf?^)^jNdfSof5fAM zokN77{)+^Bc>jm03V)THh=<{$ABvQ0TG|cV!8c@5B6fB4Dk;FzAW$$ z=Rcf3#I6IxzY6%OfDfJ0gNp)wAi@xT1n|Kl?9$)gf@1*JF)@btV&L!(_a8<;g534Q zkU<^94*+}>z=v~(4P3Ht3f~W#6Xb@rBz?@n#h;VL#$E2mP1;^#3`rz2PWDsFoU=A-C z7NIUU9xT-dx7j6w2=mnESbBCHSYeM>qvgnWq5uPq_{ z?-24FzybrppqC24+BGmIFq_GI|y}1`*~bgA3M60T;BtL%0`YEY(^@$jby5#IuBah*0+&A&o^??>x95 zULcf1gyV1tTrfX}fVlvnL4^6233vq{G>9-i4<>*?g!{!UaKSyl8eGu+pF|emuY1Ys z|F;qL>q~HL1=nvvdmzGn2mZU@&>+HbCIbkw))Mmn4q+8?@CW2k66*gughf<@a$*ox zr6$ye2=!?QX^8M6Edl8WNDniCL4+US|3?7MLqKCm zVHHk7y}v_Pg^N%RBJ|HoNJE4l;lD=*4I=cjm5|R*$cG3&3J_3`kPi{o7bfJ35b`0y z{Yrw6FGY9LZ8i2NhdjEGKoF~q}5A^Fw@COm@m%adD)(@RgFXMie3*ty?Z4+B*c<=vJ}1F( z`tLdDzvm!0PbCTWM~MGD2mSkV)&IZFK?`&XgfWNX|9_Q{;Ffi<(s*y{{3~J4$CF;L zu-{y_yR}igtLlKt=`*j}&)Q9NHH|gjl8U3T>-UQ5YmNn9vS|NMi;H{gV=#j>qR7kMbrd8 zbKE_$|Aj-|=1)V~d|xFLOJC3HIiGCH|K7pYxXWGg+BeZ+JMG;1E2&0LJ9b4$Zv;X} z7oHiA;T>iQAKY0ezW-wjPu{fH7Sor{!vrf9>;ylUmVVG>)mo#WC}>!mruCtnPQWIA z>p{8RZOK2>J}L5VH-7MX&pPP{AcS<`85kM9BzK&%JlpR0Af2}^?O^Bbl)?F&Ud8yI z>?Ubx{l9bjK4>fGWDj+IXZ)&rwB_^#k#3qeeG*>_Tt~}U)lbtWoPiM1g>xJk{^=79 z>4{Glj^krWa;7V9XC2!y7eBo}T+i2!RigMn7%p$NKQfIPFKNb=<2R-2Sh!*p* z=nJT|{#^R~TgWRQgmlsSYN(A@?=$558T)Pia0OE{9l1!kTGf-$Yhx*Ct&2r6rYeom zpL#wn$X#?*8jd^P>7H-o;o)p*HW;aWXkr9nda@;jO3c?O>mkl}alHRiU~Vz(kc zefVl_@4IS6hVLVH{kb2BsVv&A^%lAs8qG=-&0o9m1YJ+w#tyZw7y4?XS(DBj=vG>L zWvI-KC0NznKrTV>jyXoknTDp3J@PwEdTTMchWtK3O7u4ijO$omhyb=_15?1 zXRdE`{DX35#T`r7lN%hjFnnGY=oy{)I=Dsv=NS1~jaNjny5Qlx8H_G`#zTge(o!u{ zc(md4WFzCw7mWcv##cU>m38m$d1$gtTO+pZH7(7{BU#t%;~meQsZI~UZTpy=-afGJ zxQ7?R;>)$`f53O>C|>OEDey`0oFeUN?mmYmd;3#8M>i+FxSV({soR;VrZMQ(oqgY4 zlhy0RZ8iE)wRMe{ocp!=6gQmM?MM%YhcFq)yZ`vI{4RNUo?*Xd!N0n_?i*)1$wHh# zObe;`v+g|Rh*lvdh6X;dXzgoS65oCNUecW4a~mi1o+vDl^Kq!vnIdC}cS`nhIAvb# zw)hznFFoo6fOz$wEdN(G-8M-#`fk*~)xHo>Rw{Y!+dD?jAMrJYGT3e8^5>Hu_%D@b z_U3k-wxn<9*Nj=SPk=$+p3@>FNoghvqYL{48Q$8L;sM>}Z6l;3dDM+XVyYrcw?oGh zULLmG)YZ3tub!>Uv~@+d9*1|$CdRzCf-0}}w>>;aV=iN$Gd zmUuQLQa)~O%IIi0B_M=!vEL)%w}(dRezT6OJC>`du#sHvtLE15+FWh%P4Su5baR8% z^7GGC7P9MTA7^%C|Drv}%v5E;!Kt^__Vm|I&S5GJ8kI^SA4*oK^PBJ$>h(T(~oLOYz5yWZb#+@zqm@ zwnaTn+1N86_lR=-(SZXKeqLUW`l6dl+~FKR{lJXXwKIxMabwFYJKac|RFXMN&ho6b zdAhmL`nJoCs~=>$+WmJ81?Jl%9c8k5Ja4smF3I$Ryw%pVZ^=Yk2I>=TeILQ-vS4)` zjxs57_Z6skP^G%4y`InxF1R~3m*XFQ#pU!!PT|dv?W|9g)A!!p!tz4v?Ny)kF^)FX z&*CY@3!dIlFD=(J{eaPh_c~TEfcToCle?)L_;o3+ z-iW%f)rzgDjn9MaYSwyL0nM0+nDKP|#hxbeJgJbWh4LfG1$W*~DfKp5ZVE(7Ehqr_`SaSWo)}5VXRmVK5D%h-ZNTkO#U2GHGcJ->)69L6+ zhpi|M-h9ToOECG71f{3NV3pCZ{CevQRUm}oWk;d_@sa{PPwJC)&}^m zmiBgv=pOb^uaNlCv1BHyl+In0V=uqPR1Tki!(;M<4u3D8f1C9N56UiMyQpD|?k246 zlUrd$Jjw?9Unlda`MA0Z7(KDQZ(CtNW3{`hQ)x3=e(Q(6`^GiUph=m-Cy3GAjMWVof7nNTrvJt*nF>vw z_pEu_xe`4ROy-Y?&!0P(8YXwB?sDV6+exXt*(yghFY2~N<;2Ss9y|ECTTvdr)*2_G zjM3%9>fRRXFi$QuY`HU1vVnQ#QiW$*fWwW;ES+=wr7ukcyngJg@JUbA+;Y0R`<3R! zFv)bI(krE`r|Oh(z0?ZFAMAnmcGM4CSl#&IY%^o|!2tR_%tAed>c?FU-O+E|U~a-s zP5v&iiQ^o1gE?!~(=XiZOqZk?(tMg87z^Wb%7%qrWnU1gu5yImu_0Yo5HYk3&=GZo=9w}{i1g)%jcoG}00;j43uBQ`sGmlcNquMfLwryZ+RLeQg{pY3J% zv~BO0hiu_@ZAg~~tGn5+oswfRsX@VOlt%0&ugk?x4PjZ~4){@X58BFexjKpN7c;y* z-oND{iyK^&E?~w(NY;_3Gw?(#&Q9 zS9fL=^+Cn*nvqLhIz=w7x|0r`M*6>o=!~qXwwG$`Ic5>`YR8GIhrHw$yIL^1Td=yk zPZ{je&wmffGw))f-$m^%H5?w=*{pERJ>WXcq5a>)x(pZ1 zGq-Q__;kDQ1lt79s5wTL53B3>=JyndjmpuR2{fIPLJbydSBv_uX{c;5-tqD6X_vPK zYa5y0mwDBDmr!sgef&1-@koy%Xsb^`l$hMMSqYu4yBOWASY5r^`^-|vKkq@*nE98F?=P2odaddAb&f>!wq>WiyZ=ZjbF=JH3xL@+DsWI(s!=j3#;B{`Kqan$Fns zjtEwFa`FN*`+>%=xi@e37uM8i9`}CCa)Vsrp3|C~v(gmj4|0r@6dvzjV(?0sGgt1CU2|81jV8^t#k zPLkdQeO$o8y(}Y@Cux*7(hGZf#D9Ok?8!6Zsj}vF6Z4bted)&3DgL9W++n4RV$(T7 zig&T=O&qJcsmj=Vurr~3u6;)v`_Bd0-vX3AVwujM`esFE9AGV00z1xTl{s7%X!cYMEUy|wyRr{3cVmn2Ih%(ac2ZKfLq#h&fI zCGpDVi(3drR|=~exRv!p$vcXjszToM?z^WS@Ap)fznt7{v!Ku+JLE65~K|WsPJM-q^I^UL-z_Tfuzvy>2P#)h9EE4iwdEFDPO6T!e_IEpV zj(lm}Ip)mo^jfUVEZ}9GE4%6CF0PXg(+WJwU%1KU4}DqdUPH34H-m(i>b!{r)Iz$_ zP>^8wOL^G}BJO!_qSNj*>@zcczWMdZxqOOVzQ!GGM~@$9kNBig%Unbf?mKUBG4t-l z4>oizVF|kaSFSgV#L0}c#kK++q$`6&0pfG}nJ)x92q$-pII&rAB6ZU%8FRW_S?_yC z9$yaT==yTwUSWqyciH294!7|m(@*#MkvtU{v7G!OC1tU2|3!`HbBwMmR#!yIU(pCp zKONqhvh9bj^!k1&t7<{qrkMRHA3nx#*i)Im{vCB|Y|E{z(Fv0i4fhL8N4|$h-n~Q> z>dxNIjr&!K(cO;KjS6W>p79KSUUdJ;)mPHy88^OFvvV=1K78i9?o#+Ay7;Y{rLHvYai?_+Dw}GTITkdiyY(G*`@NE!d}biVsv+6b?Lv%6AU(vMM<>6tPLC-MAt?8^(31s!3iWZHr@(t=|}`XytlyQB;!t+s0V$lF$Aa zT?MS}Z`nn@rk0mhE;9UUa~|r+zUD7`F5}8;N-L!DTTa5!+>iF|Aw z*wlZ%Rx7XE;OyrQ8ugdfEyC|S(fn1!>SpQ$J^se?vFuE2!+7NJ0pEq$GxZ6Z)!Ch% zj5`c>+r7C%smF3dsrS&cW?hDsOxD_k^38dSBP}sGa@qly>x}^ z@hViO1N+!{=s8HeI5cB6ul3h!W&U)b$WKSIRg=PZZBe5}$F$zB9N{+7AC&sl!Oi#z zyC>wKAx92N-v>fS7d|T^!)xxIG`+a@ZtdW8iKtlHd@ioU{C%V+6D2gQoaMNFQ*QR2 zjqW)lyg5iWgVK;!H+x$lCAIH2m$Uk`LD@Hq%X8qn45X`!L;>QveqAb#YE3(GcWg(+ z$bJUeMJWx+GN#7OeyyUH-*cB-Sn~_~uT7ipGt}@}mYRMvGtqdXQD1p>=#ux=>Jv>B zTaqxkDp=j%EpGYS>9`AxxN6Ex^7tbbS(w^>Ns`5=ybhF7Lg0r$uUBh1awn>F^dG-}cHdTy5WlEHR4kTXcEXb3dBd!g~bj2Q?%L5dZa( z_#?9on=>9?m)=n~m6-m!@gR%WCl$FJQuJFk$%N4wiHA$N-Tzvy?Y}3`-9P?=bKUEQ zIysUu(Y!o+S)a-YbxOGLffq^Jha;UaRwKSKbO1?H&ALy zqGPYh4$eC%dFl7;nOvUQ&ahbO&tkuu*%oToaB=;_=zoo_j}A%EScvV^eqS;4Je78f=Ioz3bgGsB*v zG_bnLA8v-sdY%z@>wrHv!am=2Rgb0FQo}R+4oS)Du{!_3*xzqmO2r-H-|SEEs)_KZ z%qte1rjYOs?7l9ZE0I0h4={% zO)+M6%Tk_a`r3A>g|=yituFQ#~XF{D0%(S>^(GW5P0hu4Onr?}*!T_~cMlf-Gm@lVMdvHM#L4#37v11W6pl0cl&{!)GeVz@;floR z$AZ`4SfTl#jYI+B@AQa}q+HZLGC85T)2+y|kSxhZnLL(52tTQ)cq-*o)X>IZ4)2mW z0i`l>r`bE(n}3;p*EH~$!H*|tJ=vPzor}@c!Ro$vu3B1Hd}EHu>tn+^aY`m7ua~c? zK15uNTij+|8GLE_>?d{pn#Pvdu=mwT3WsCAEZ*{}SlC}LrWG4P?Pj7GjM0UAF*5va z!QU)5=@O4>7QFH*5gfG@-De%0J-u<_)93Pj&QRaZ8|Bv&3;QDE6#BheZgLm*%6jh& z`(nYIo_W*xOtJRfH9!dUgB}tEh#wj9{BUpk{*K&Rf!Du_#+Zn66zFIqM40I+Hn?Al z8X2GP(GTnauZRLA3^zv(N1jvFkll4?F>@EQeQd2Jxdc2*Bi%h%-F0mb*R$X)-S4|N z49_|2yGPUaF(ysRyk=)=(blVvGs<}%J`-cSeAoH*P2U8o)&Y(hW7+MNwgcO=0`gB9 z3?&6%boH^ie0x3z1w2W%lQrdzZXZ%uA6iRu0Dp}m>+3v4_1aTqLCWT-o6Y)NyKuic z_~hp@)U5&%yoANXNgYmd?Y+*(Xo}G_!0P(6=FdJbYJ1X6BQhD|eTi4mFHH7PWj;gt zBP!DQt=G0%u+Yjsy>U0){CCQMC`GRyoT&?GjDn7J-%mG(Pd@7Hz~~xcb+3&ovV77r zwyh9tm?bOsIiGt_Z?^W_2cx_RF4}j6?W*&cyPbab^yqv}4x~x(2$S6&FO({v82|9H zYkAx&_4GW9t`Syu?DTxXu8dFI!d-2fBBv$2ZcBXD-X1Z2C*XvaOMKmf!O@sXZKc7% zU!ftDJY~`v3q7{CFT73M%3V-|tJQj?P=e8g-#H+|zc|^HzMbVGiQsGkU)a*~r3l!-{Z4kb3 ztN#<<3Co?~rQz%2Mo89N8PZ}_{+wN`t;u>bBWN7^oV5>$0>s;FqWGegV@APz_1baq z&##|V%O5=vLK0r8tsl(sC8)V0>W<$_|I`C^?~8v1H-x{a($kt)wDPEoj8ojkNe_?)o9DiXs-i|xfqTPMSNK3+XYvj+Twvqyf5>0PA1px4nqn0>62hw1jB zwrUN7mvq6GoX#uvDLBfV47+dC50t|*B+|7&q5$zz#a$cLP+CTYmN#}OiE}-Fen4%l zl@Ez}0ZX;TSpnW1pB4ie=&1E66ZWywZ{OK!(4Mi4g6nPd`x&M4Wu2$=;h7id?#Jrl z2D!9x+4(%?HNR^#4wlTPyXb#DL3YC5(_!}G;Fs>7KiW7Z9V>4K_j^+JxOml_|1n*- zjaN>6TKJ^rted#SWsI&RR`-%^xCyJRMHBt0bpl^p@a*LIIg(y|#If(c4*du1{WK z;+fY6U--!Dn+llqSsqUk@K zx^P^$H}Gz5=d9#JvCk7Zd1(RfM%%!hAz3=kift|VViGJrvFAP;tnQFCQ*&DAaK+Iy z1&ckKFJG(1G446laZFd@_g#{zaZ$rHj}P0C|BzW!?IkiHF=FiY?O^T|2CzJ0M>P;rm&(?GK>{;YzT9kUo+d#vtkuwSLX zM}_N6?DZ4sKFkko4Zg7}C_V6-!=XV=+;R4t#WD^nNI zf8Dh~oIm5{+qZ_wnQ>(s-zP{7J&=|d{v~t0^Q0uzgT7tG7FXEv74Td%-4*Z|2(Du| zeok23Pi$+#j$Rb6>?M)wjdV)MEAgu3?3^smmuM;2)r1j&{_=Kl3p(`Ubw5^ z0t;t&9pgYsF!P80>w*tIS82a~=EF5cvQCBHJzQTTD{MqJ zcprY-o1v^18gMP3{S2P5k>L}!i}Ft~sZ_Y~3kn1sdS`t)Pns=8rj2DwuIurns1LqR zP4EobJ+&M?ImwD^&IDPjRebc?bz#%cMQMstQ}OfyKnUr&AyI&MfydTNI@9C{;dJkh zosvE*7=JUT<&f2t(w*6+zMJ!pt>Ng)?_(G_V<6nM@77{_qq^s-u92&(#$%4L0W1&X zzr(XX(sjq`D#lJR4z{TE-5EdEF+w{0wS!BxP~t?LjC!qkAi1|ek)?$I)xhONm-&UO zPZh4TKS-?O)UEITVjbJ9uEg8sx#Nj!symabuo7m5MXu|8fteqJ*`F>G8O-FvB6v94wD_Z!-~?^RiHtPX_v1+P7fiPs0KJA9m> zm~ZRDrbgp!Ui3RJIL#kd(P6eaptG4(omEhwJA5OPc;T<>VL!+QYt8nReevQO8E*fb z@^h0~DV_D2^5Id8E__x-hF|pKmF%7of5A@Hm1~(%spRwN>KYO^1LsX!B2BhAUDQyu ztK`&wbWShp#ez!q+0Gemw`09Rtn+e`74CPB8`xmaeFu>!KzxR9iGus0#jHpANCidp z3%|fkFSiQ`kZ#2*Or$3Fe6X2P<+VDM{4i|8n}-KvHm{M=r5zZvGUZN|{-S$uw-6O} z-2AY*yPuhc@5A@kZxiAbQl%!nrysp*(lj7azq2yab5?VEoV%#v@YN~FJySC*QaA#$z^H3ai>REi`JC#Y+DmN+qk)R zyTBJ}C++m-6(aR&QN|C)LNyACBX6b7-jEO-`?hQQu^eZv9wkm|c$P-G@Yx6%o^svB z+5MK4Zjw~dc2)iBDNokg@9oLH6gsKwv@k_~^x*p6&2==i`IqiQouW={xffyR5|rZC zH|XKAHGw5N=UosGLb~vs7c%_0pd=Z0tC>58ljr-^9=Z2iQ14Lr=dTv-Zx68@3Y0ra zPFpdzLB(Mxr6S*`=6P>E#Y9z6se}XXNk(oR$!W!qd?18$4T{#m|#a(#u0FTGW+FSf0J1ai;m9Pslw8>sw;9@P(;Td>m6H@vs zrtSqs7tUp5_!~6gsZ9$Noedqe=^L3?9u-AWSh=1!s={(InJ3M^id+0k8*laQ3BTL2 z3=AgnN43(bE^ZXut5UDHwnATH-WU6Q3+xkQ_(#odq-(dcSQZ*$Z+0XDXO3Qs#52g)-2`47_*HV8T+Ttj$9a0 zr9E=E>Ur)C)^)8w2q+9v+q3bLE-JyWMJeml*}}Dz-8up76BUB=-AI z=yf*F`;Ud+=`t1Rn>H&M@yzpAS?{F1PARm}%*9dB&PQwc`4~q5&ktcp6d?Zm300dt zql^XXf3?wv;7No>&*li}_myn8Tdqu9D4$f+y2eB>Yu#{P&ks(^slbqtJzr4U zq2zeN_4)6%#%`sb1@$XtT%J2E(z?s&6-nluyj>ZX^@UGnm(-IR_WO9m!2j(#!aBWg zNf*BqZzNWC+UY?P&2(J#%~11ONk@~$=icl4CdO$FM7z#DRH?eFA>OSTWY9w{OSi4= zZO*Mvo3?p3{NNkg@O;C;-@2~C*WfcI3HVz*upf?Nb(`jQ&q(n!J{7o0{yafjJ3f~t z+w9ut#luqF>N!_)oP#6Vv=hWN3leGGu{yxPc|PqqeFX0>xZATybqt2Y_1YV zd#s@J?%*E`HHiwRjThHBkWR<(@33>ucyHa*YFOnBV0EkZ?{DR# znCeTuFSq7%_|=Y|gEUMfemT4a)J_e7xL-c5y8RP3yU%`oO0wa6eOBXzF3CBI(;6R+ zCz03kSoSlhF6l1+e)0rXcR^$QW7}3s$#(L)4{KkfsTz5dH<})7v1$FdFvCl7#qYlI z?RSly6p9I_KFEq{c4hsJvmm)GP%SoU))7croUpt5*t$7C*Sq<9O@ zPM~X-_MJGau7RnPB3Erweecc?|FtS%!v>O4TB&yBct>3S>le2boX-_9Fva%rB)mT$ zTzO^-iGax!ize+m>87zsa_0|4gl$^VU0%oWSlzkPQKRWwBnEBHu^w~O-m`P_jC6sP z$JhEDk4o<~jy@p`K1Kf1O|sDUO!Tdubct|Eq0>B9c^*#e;eWOJ$>*CJm(LIQr8S&@ z)iv5Es<}@)+wP`KdsTjz%B$#&+jk6E>&ESfkLfvVI_AKndyG79&x7}u&*p67->vc? z%|koj%;A3WZ-*o!D1%&<@0&OZWuTde)!lTh`4L<9)W;i?lpQ;j+z;*hRFlbC1jIy2pK2|iA8-_0Kofpfgbcs? z?tmVr@&10MY1Z`~xV?6ou3-|DGlbt41g&LCXahsQP zBbIbeVRbJ=D!-nli?{8N=kO48xgho#6BF*33}aqq+cNVU}CUa?#ED(I6V8K z`+Npgmp}7q*3(5H#-!@O_AXHq8#Cssmgl!!4zV8m$-Q02TwC>C8;jC|yy-gWHtBCq zhOg`sie|`Duq6vy==4j`wI}p11zd2MSl#vUEG{QP8#-J~-_$aYW%e8oib?+2Aj@){ zuT7?rOqU}s@^Z|g=_5riDJ2d|zO#yhg_Y%DoXxe?!PHc_UlicI9p2xd?pdtvyDL77 z(L*)lYsl%8@4W4J71wZXm+olHn<4EKgLdf%;I3&O>R zRq4pdf3iFX^oDB;u48zA%fjkL6)>GFs*jUrh&)==os(0dx{ukI_i@U5?GtB@9=Q}^ z+vB`X>p-Gi^_q~7+ZyTNqC=ZyCb+$$hmK?nFcU*+t+tClYm-J|7xv)iTQ%i6Q`Yt0@!Pu)M*E@XZ_S9+MC>v;h! zpR)1+63!};-C5r!NXOC)Q`@XBGjDEzXLNLaxPaAF+RY?w&l3{#s`~I;`;9e9{$4%D zl-``(-9B{cN8w=1muZeSCl-pCtyHd4(=ReK2x}jH(#-HWPyV4ngJFDBvBHur=~B?y zSlt4;))b54K7PYANqn69N9q$&reZ>HZ(~-UJ@1_w64?LygKF{y}d%n->=XkxHObhe2Z=YElz039PDH&L^_$E%z-Focd?e!9(A@^nn&f`DqxkfiU=hjG967P00 zuf*2Ug*)bUym{eyb#cO@w?#uVCOi?(JL@Bzd@SaI{$ZU5v-UbTMvJc+=CQXvchkii za_LUb_RiYbe9`<&Bh6d>e7w0kD1Gj7_U~pT4vU;lo4rJ`0_++YF|qa zquSW|rY&_tJT`xlaz9{y%F=XsfRvtB?8-ZB$@!0}s#f~q94dFbxs%L0?zPOC3v!|i zV`HbvM*(_HO5VO58%9Rn9V58IZjE~X%#Jl>dsm*%fAM1N`?i+)0ZJ!tYepvWy^>=% z3uX29mflO^-9_ddp}nsnBRVPf!|_{!1BSRq+*}iu8FfQaS>V$t*}x~I6H3MWJA9^6 z7u2*xN+((lP@HxA`tj6;fW_w?Db@#1T8M2sS6>{-5>Q5Dip;YU8gcN-M8O0CeL%> zTm*NcbR7M7GMe)2(26#t#s@9+wqGUFHx%z$6H(zWJ|}zr?1QIUC#Y|FSI01I9xh3( zJKM1RV28O>>?+A8S7zItU`Vq2XKX)1n{YSE{MqT-EB!9eo0FMx42ydL%xeM8;qSLe z_F4S;ijzj8^pHf^GPBqHv!*|?+Tz$?yT7?MFVuCo?AIbjo5<^`ee80~=H167g*~On zx{v8mmz|FC)KeW6ht}Xeptq=yqTrB zW%oD7yt-qpephi!?F-=G+BcWXdncjwp2oGeY}d0O1M zYXze+7Sf7#Se7`PeYNe|IO$CPA1|ecRVk_$``m65h>Eiu{;lY-O43R7l_gA_EQxfB zvT-LapT{{=uDEqBlEjHVh1#j4XsO8hc!el^4v+6klW*KG-e7GRA;)+0K^4yPwQE@^r*Q84m$t1AQIs5~nkX z(Vcx&G>mAc+$en4x85--Za1#aEZ+2D_WR&Q9rH?x$&4{80;3N+m|Uc>F;B2hf)a`M z0GT&IW8Dy)h5JLkDlNXG+8MalWWx1f7pp9qPK#{clIXO#o0c9GyCLrvziycI+{qo9kAl`YK5GbiT5&+(%9saf4L{UI z9Y3?A@(yLsl9|cH1se?a2&MNw+d68$v-%e^5-&cpz}+atSuRJad`dU2mQt}Ami|uE zYV#g9!O_a5GD>qtjLF`-&#u^Rdfb7>k4~z-v zo0ZGn*Otss<=>Wf|KlYgv67CLCxi;z@8-^@e zwlc2sQ*eMu9xL{0)sdaTEvt0TOYMx=r?YqYDDB{H=CMXjI~@eIOi213CG(D;U-RCt zdDbW~A#lW|`dv46p5Daoy8XM*;N`;uyjB?8TrX;rLSNzIc~xRbWuGVK zO_?>5a-2GC9lM{(u6K@+d7sTYQ29>7r>uEz-vypa%IccGjoo9xqq~||JMzc5BjPvI%^P_?Z=vPX*_@8S?b$oH3w`e=BaM*nS1Vv zi}E58?+G%mz<^T|tySN-EN1$etoCu3wJ@}5dB|nfL#=YjEps(&t`*rBemLx)tU4+p z&}N8&`)4gn%@qPcU!?6U#7D%4kB}wto+R@cyv`o5roB^b?=I2F@3$VQmq@-jINYM) z$5`C}%Lf}|0!JJiqq8it)oV_eb*{(d`LSO`f4ptj!_bdOd0?qBX&jZrdy35ay0X&E zBq058Nvv_nku$3KcUhITwGyq3U(f39vOXCVtRv#>mHT$TWV=p=a;kZ9U6NDOontc( zzxUmr?%|qy8T({h|2<9SUF0zRYWjral^~i3g6)(esY0G!6@6L< z3(Gn`P@*4DFR0S{;&(tzyKhvw@xbudM;A|0nP+~Gc+Ze|6F%BBy-@X+@q73v?7P5Kie*W2wA8MVjl*4GJnx2Dt!_+Oi_WBJ8Lk4)Zwig0{?eEGWid~e6dS=(sVg35Qu z>lbIqywshvW3uUj>x!-o^&ap(ATUp~!f{>Nvm#H{#5=|hn8kM6G`Chnm@7Q2?Yy^a z;+hk7t5!5eTJPET^6sI-8HY}h>xXk>-f3|eS=ZkT?qhW*Ei};auCd6Qr`bAN!#eWl z@plf5;=f@U=1-=;#-r^hl>n(TsAtSA-zP8`yT8_MT2 zclrGZLuoGeNWACCyse9v-$M>>IsELNz+H_v)~=F#54~Z*NwZHTzPrcA9Am$GZL^~K zC(pT+^?OyO)~ii%Uf({RYF#AebMA;9_0~io67K~vukb30!NK@rr6QU>?)f9ByUUsR zM+F-yY?9Zv4!WCaK7a8eSD#S^8O-U=9}cOL>Z9c_^U44X?HBdRnqTI)7iqEQVc7M< zMKZ727Ga$&e1m2m~Q zb-PQ2T}3hz!xb;ZJ?MWRO&Pej{##7u&2kLBcG~e?OosK4QT;NnHrW;{d03V?O*v1w zTDa=_-ty`AVsi=<5E}EQfxL|lo;=1Z>pEf!;YRg0vHb^@d zYj><$dQ4yviT4tjH@#+c%(~-~H%OVw4+)sGYFeLlj3thjLtUgD2hfv6Wz98xY_h8z zk}EzdR(?z=rlSJBY8ifv3UfOJ!@4ZjX@48OYjdb~I{fCb#bGdtPMb@BH zz52^G%LT(KW@`*Q8M122Fp+HZ0WR+qE-CCOX%7;#YJsXPUuvs<~eSBHzBFiyVOGJg!rfnZiQ*(_EYz~8l;_O7MoKbmc(S# zOt|w+!!@kqZIT3ux17woQ`R~qc$ws;kNxg6y2{wP=H|3!Ef=;Hx;eo@!Q*4lol({A zXRV2Hm^nK7;E$6B(?&bfSt z;+!~lqcrS4Bpz+u`q*WX^U+S7sap<q+>rq9X zYUF&~>9wQmc%N)Led{2bTm3(f>$7WIQrJ_LMWlROq5IJ!W`*2&r8|>8&FjB8XOghP zBhjV`eIcP2gS96V+NztFDBiGMSpPl1!}O~>LsUgsY~KOr>*M(KBOe0?*B`EvdDpuR z$qhF&RF8N&A~cYN zqIo_6)@~CmCF&_Zay4rdry4cX7mL4rJ3k@L!Z+#*y^0>?@U>6vr&v3~cEy*IuD+p= zcyE$yjE-@9OA4N`y{Mlap_6}km9~+k%7i{7esg}L)#iO9cR5z&s z;hovqwbs+VGLoaOY?J??<3=uTx5&K3n@p)oc2tbof2qS_ydOjaL`}o%WN}@5(uk0+2ey#42`y{O;aStWz>qis&Q-%G9#vXg6 zGWvz+^x9aG9jeH@w7Of7>rW*YuN`2XxaRWU9R{i@hbaRJh4i$|Z2b5SPZx_nS`?r+ zI!11uiCIWa_KlO<+!v+i3`epF5*=K)$K)GNhd0ywmEs=4{Ea3xyT^=e)SL zTB0)cbcCQ_ALVh|GG9rqoFCa?h8EXqo}diaK^hiO|5AqskWEcwgY$N6*}HUf5F;hO@Wh!dF&XLpCLd`(#_7iCZ<1ey|zqLk~cih_&nQL9D{8r_N zV6)2G8u=MFVwas6{IyvB?)+7v3vkVXTR&jmhr3Y<84_2X({%5}ye$3L7Go$QWie*@ z7Vi}&=kHx<6mYQR>Z~tgKNUILD&MiuY;u)`+JVhSlWv{Q2(h?y?u>!4)he>S_$&l> zqvYh?Uomg}un5Oft=|#`j3~1@x%K2{?W?RJkK-gbITiRAmZ}Yb4g)O88BMd zq1`}Tzb2@T5fh^vrF!(}Za?~z$1xQj+HBHJ)6`z}eO*6Edc*Sxxf}Gh2b9|JSBYwh zjgLL=Ejv!S!;U;Id_d-1x8#my|Eir^&n$}ET$Z+S+H%*yEsa}+v<6H)mBAQtN4IF3 zZi%`vOZNzuht%+ zSua=XVEIZ{X|B^T_d4UbZXSt_*+WNWubOxz@>Aly_i`Hh+mBqyQmulp2gJxO=9a9q;&tLWB1^=Siq`Jz;}p6X0m zFEw{XktOZehwb)_qn#~}eK|Xjr0*j#Z|U)3rA?FE?Pt9?L0LI?duveSn~*lwi2bUe z`qy4qj{az&SmB%yVm5Nfvljn}1(!2BX)22SJwEl3_CAuNB3|ofi8nU9f7y-8H(Zi=67y&M3^hpRrZ>-I#0MLuEEe>Ml}}{F-qoQo?q5 zMy_YE_J;R^-zWhQ*B{W<+>LVMx!b@a!fPJIO`I%nT`2D5j*oB38uNBW&JR#eusLUZ z@B9kYswv(r^HjGLoA{J0FZKUAt-`~hxWv`zX??BKBAkQb@;>E~!k*$k_~GpM$nzTq zKUpF;&7{R-lsu(*QtG&hD$P+&PE#)=#yQH^iA7YF2itzCRC%5km08nRHc{23nA zd)cYK*w95A+OAym?Gx4frhUE5Ra*F~r#2+s=VacOb}BZ;0~dBSO)D3Axpscpbg`t* zUZw_e?di5NMqW48EM0K6;-lym{tpx%^Td||RlDdN0!A0}1J2E-JDX}uBlpj64wSo5 zVy?$M4xWMmx(K7qJT8kU|Ell2|7x(qO%#I(WPqvM? zs@(VR`eDv1AFbW+QTE)7-EErL^zSIBH~>)&FzHC5Uv`0eq> z;zQ{=pRxlZHfG+okrHX3sERF{YPnFx^!?*j)qN+wIJU28W~}$exfd=k#=+evP4DYF zhN`R*S@*J`PUZWynm&Es&DwCxv+s_alVt__rWiPFzq&`@N^zc%T=u@ry7EaEwO&XD zHNBd>edB}XO}Q3pfQZZ6#3hA2<=l%w@j^usj{F)%uj41a^}kYgdG4E;k<2jd!2;Pv zt<$u}+}(HYNUD0+UGV{`9SJe{2LktB+&;;{!_CD=&QMa1#M?~f-F^O=2fg*EusUDr8Ga%^Yi7i78$-93c>Dx-?HM>9)D1A-W+MsOEuXc{LFu&I8l>=Ii z%Os{wmAR9!@7#$O7rc_AP4kuPPIoY0XuQ*(JnzKN@udPcAM9!yaApjH#QTcOEAl#G z>Vw#;_m&$NhbF45%{UNz-goB=N3GkABhzU?wRx0J+f2$FQuy@l@4vV_MLZ;6&&)Vh z+xK1NPCDDPzU-V%;%y`I)_&!CT(5CWQa9X2vN>t)il99QGF!6>a~{jRbq%!~#7J4M zI^B4d?d+TBl8zF!N*3;cRXZ~xO7FVp&6@vl+XZqteof}hnLRb6gB+o z6;|hGQ%b|P^?z|}tTuIVK>}aHgt@PzzVm6stT?xCf9-=i6~^nVpI1Ep(07>rWfQV} z-;jB~S2!6+-;N$|Ai(>%gIneO^I@@$Ih#KZux`vR*}N^Q_@2MzNE;WCCv^py48{6E z=3jzdzPR&EVz^9h~uH=IsaYTs@e@#@f$g6XSjNA29Ox+HLNv$5LP z@*@3{dX+&-frwk)-g8M|PZ@G?+KxLTsNr9$Wpv`MsI2{RC@W@E)E5oUR-q$9HjT6? zh#kk=Ju$4XeRbXEg?kPa_iczc8uMQIfS+H}I&I(+b>}3&mS^QSD;HN?R8>`MsQ`Wjwia!0G6g>Xi zQ0$9v^~Jdp?nWtnQTg66IpAyZuJ@nLjN4dUBX=pe<%$YRE@E=?#m`ChFTcfi&aKWZ z4sralV~psxUAG=Q-t0IbV|>=j{!T6S!~B4V%lnZ_3VVul;VSJB*|qm?y=xa*x>ZM| z)F+b~RUn=-@Kxu*uxFBO;}1W%Hb;KUxq15(D6(Jlwr#p0Kjqo(jLwy%2Te>pFO&Oc zpUAvd-L#DsN0w(SnorBITKm;y`=yM5k_&0(Et+Gi-|ZhAze3~k#*T`ZJp*4JNO_$5 zZOwt>7ULc)ALsOO^zm)9#S>^GeLs_VTXuZfb;>zj&uOOoiz(vK`}D>w6Rs`1u|{C~ zxTaTyk9G#f<#|fqoAvyRVYMT5vS5JvlE?b8Ph6xck3{vgm0PMo;_V>w&U17v^cg)^ z>WI{QkH(f;Yfc;Z_wC0>DGQcysLd?K+2VA^ z+|e(Jy@l6~j=P}X{4Ke4Sjv#LM`Ns}KO3?S$ME<(K@9A_a!FxN89&uiExuOutM^fp zBaC$_4@b~Lv*JoJiuC(@N~_vE`K+AV_phP)%pu2u4g5?!Z(U4s2;A$*oVQgq{fT=t z|2`SuKwe4MeGR&s+$GEI zKd_%igb2DeiuT~FhIXaV%Pg~wq_+)A{Ui1=iW`674F$6GgCJq;bY0sU2lVDNp8E4 zv_NHXvBT(dtdN_|2OBb_IJ_~i??dL@*f#7b|3v?fH#ByJ>b?kwJK7?hHE6!b^kwNw zjgHKmS9GecP!z>q&RV-O_^=n>nX{YHg5MqaVSn0Zw1SXBeCibrFZvmU%o}#%v)ahh z&gM{?YA037>1!^Y@Mv-D$LA;QE;!#_|K{8(M#-GhQ?gIfmJS#{M}(>=H+@d=E6+3e zwq?Hgw`L1nB6!*J>-b$7?ncQRSsh{O&Cqtv-npFhUeWQ%o#YU?qcNsqQ(8o&S06q6 zsIJU0Y4C}_EwMjt%&5??VP!{eu+-gjS}O9$!PIlxkC1q|-?u?-%F=~1p12C9TF^&t zYwpK-^I>CZ>2*;j>!r(UUil2I(|!9;&3Wj%lGr!eE-ByRj;>fGKU(wSg*8p4SzD6@ zjz!!MCh_(q>wD~sY>i&y(v)4Yd^yH)ea1g%SGl#+CqrJVVX4}%rMgln`x?_8UahfA z^2nQg;km40IxAwJ)(X{{x49>R_^oK}B;I~x-r<>^V)qYeYPqg?!{2dP-0`Yw>RyNO z1EW3H+V)?2ZJ0vI?3E+y)@|A98$5Pu{{3q+cB{LM+_mSa`_pCq>!spf+$8Y|lX+_< zdU?;ik+~`NOqGk&mSgM71w?&1;F-JD@17fneIAp4-pX$Dp)Yv~USka#WsWRRvvK4f zRd|>3_>iURV(o|YJtSW4_kQ3KAEh~Bs=d{`39ZLFZ8|Hb%zG3m7Ja(%$>ce!vLB|M zT|Rd1#bi$d+T_$W!!7nxG&9aTTd@9_=oWd z-=TEt-RqNXa&=#G>~sWfE}N#c%yxz1C-c!yO)YIk?QPUiu`4n(Ty8)M>X&Z&wnWO& zS>(#pZPmL-yrN_~NJq-8JQXmn+$1Ld)c20S7gv8Qie4&U!ASRgY*%pB_0SB_hFzbZ z$F3`|dwniyyt(ncpo^DJ$lctvYTfgWelP9G<74jkiwJ#3tlc`&USP|pwi}VcQ>jfe zp6^-mwmqu-`TCGY7dID09B>p_lyYHIbl90eMlpr7hLA+}xvLUFD3b-QtVvtp)tW%k z7r%qV-6#!xtyb3r<)>Y&x*<2l;*@<_s)g8_3CHP$>?nYTFE$7qvuIBPLRrMi@&TO=anzfd3 z(YcbZLQeO6#EVO}Bl8z;9Hp#UKf0XuBv)Ro_|zi_)|8_!Ed3qx9cmh8;v5N=m;0S2 zR0{TSNh~DIE}j5a^>RX6+=$n3y-4upGuBYij+PPR$At| zI8A9yrDe$A8|II$#RTiA;G7wkSBk9fT$`G`x7I(}-7v~a&+t^*NxH`!%lKve$GtxF zJ@7_&XU!M!=EHJ%^CqVZP6!+mpVLIU|7H8G{Cfd$t8eYm>&sFm@k*0ToRL22zRVvHgt4a7%x`7$z8Iv-({Q0B_Ri^6(4vUFPTWVNoi@eO+Be{a^Q6m-PvR+3O(!qtHn22=hLH4?C-WXi@AsO&VbMBuov2$& zA{T|PNa}a)_Kx8xVT@ZPl@1&B!fl_Zq#^%{g+}II%{{Y0eC0a9W@E ziRAfp1u`!q^QKmQI&(QAd5ht!Vi)=21?@Qx`}9%D7_xE4*@Z$jamvw#k(HDdVoF^Jg!e+1!#M5kS&+FqxO;`Tkj_Zl&-2gTlv`oV>mx>U&-cRl7VR z{h3xv;T# zhD6)9-PSyLWO-I$A8Qh?B3WOT)F&55xnDTy6+Zun(&Jss$FrKUH>6HaI9~WoISfYzFb|f>|lFPt<?3=JQ_zol=Ei#d!UXMg2ZniWAM0l>$Si?wE+5%q&R#NlaDV(0rrxx z|EFR9)9=Hy1<_gl%uqUCAPhMB|A(aw^`o&^bQVl%eILNiga6Yu#W?)j{rnl8@V#eQ z_*UtEm<|30PcI**nYrd_yzOvJ=Y_?xg68e{r@k^&{m6B)NprSCSR~oZ~x`L>I+2u z%8df7xiK3IQ=`QM~}ude^|7C;}01z=y# z#^%327Dv7;;34-N@tUk1A0Ir2`9I#D1XX&s|AqxHFIjZoP9s(w1$r&eYk^)1^je_T0=*XKwLq^0dM(gvfnE#rTAa}D1iiTAkKjtAiV_`XBDhAA-y;D+xi#B1=5 zE53<%0XBTkAnFFc|K*#E7hoI4xlas^EdY4G3S2{-F#VRCYr{GB;dj~a-G#WRaqfe6 zgz-%QVAzqIYxq9rIRK1nG$*Vy9K+92@trVy>YQuvnkzo|Niy+%aShHjWo0;k?IuPF zn}~6Uo~|k6ST}ix_u!qVGv)`T5$%okMf;%r&|YXC)Dd;U^kVu@7t|m1Mg348eCK%v z5N-l&25bRD0-^xXfMtLfKrCQ6AP%qs5D!=ha0j5D%m7RVSO6>mRsd_j6aan$3f~c1 z2f*(Y;rsmYJ3#n8+6KTQz+=Euz%#&ezzaYl;3c35&acSGVk=kfjU_%3%p0KBFLfBVfR2S7iT2f(YT2>%@ldnJG}U>HCJpavKL z7zr2!7!6PdXaIHt_5$_+asc?w$V|W*NN*xw6d(|=7%&Uq2yg;817-u}09*ib0j_{~ z05`yVfGxldFb!Y?Fb3!VRwF-v3dfoNEdcr(`j;zQp9gRQpev!jDg)5x@m;_D0r*a3 z5kM^5gYTKfcaKv5`0njaxQ6d~7l30s*slYm08#-P0C9kofG9v1AOsKuSOi!MpaZ-B z-heRxDnJvU1<(fQ0CWNP?%fN3i-2N43E&c-6mS`E1yBYk2UGyA0p8DKmB>wG*m0aySm0ak!1fH{C^00)2* zU?#v3U<0rP;57u^LpUPrm}oHA{#L(X%Z7D4>W^tcTXsOWH-H_0*MRK+Q-A;<2M`7b z1%v>C0RaGi022VgiTlt;(N{eIXg|yYOb6Nmbw&MW0Z?c3e@urt0OLX1VfrTl&>mRs0Wts;0M=2; z0Iai=00RL-0C*j*4FU`XC~(ddVLue02Eg#jfMI~)09Al8U=&~^fC|t6j0UI!#sJU{ zbpYA`UC!}1*rSgb01N@>x9GFzyJ!ov(L{hb0DT7i1$_>$V^0J929MD{(0|Z&cs>>2 z06<@y4wwNzUqT5scroO@QnJ|3_F5C>Qehy}y| zmI0yxQGiImQa}V?8z3976_5qU1Y`iV05$_Q0X70Q0MY?zfK;SX@UI7{bF96Q~PXTD-Cx8clIsp2|T|h0M22c&S1-J>g0k{sh1~?Bm1(*#u z0XPmg1~>{p+aCcO0u%!F01g871M&g80CIqxfPH{GKrUb}0Po!m!0UL9`vU-JKmp(| z;3VJ-;56VY;2fX`Z~<@?P!6~VCv=Q2;34nG%`Aa|xpc&8#z<4pQ z5bc2XfOmkm0CRv1z!ZSjKLb7iJ^(%f%mEnY3m^=D?YbZU+jwm2v2EuEV5Rs2cAbFl zfNy}W0CZ@q_pxt){RC{oPW8}awwt?7QVS9*e;~cn# z>2?8NUj*AqE)V)6#))kfwqIOst}mglaJjK9KwZ#}aSVZN9a&$z59O#ka-r_16JEnM z1=~?<197~d49MZMN%OH|2AhbntL|905JMO{jqU1x>-;=+5{CQwLor^jH8*+0F%u)y z7g7UPWMD4}dj(lFsaVxn*ZT3#*D=)8)if|r1_{_GCgIGU+k=+e8pD<77-@q9q%mdJ zZZs=QpRWm$v6{L%n%YEtC=U{^duE57sD{sZ5(b1Bgz-q_uT5x<^9PBZrU3*Y>KD}D zBegJQ`ilokFdl79eNCNlbQb%Kd#4;@mL8Pc_ZcLHng(MbP%^M#JkG&w;VwU9OF*Kp zi2|Y&4hG4W)b=b_$rqL&K{5lVC;eH$@a|r|dG0qVTJ8G95^VaKdIm&^L+jXlE@uk9 z-suPuJyd{QPDDVmO)X}OMm*gEBnDs{UEW(YM^PA(*1})H2nm{isCzJ_gIonJ)_1lq z5Tl9w8165rdjWjy)}q1dV1QL+Cg-8S};gc z8t*=oPq~y15+Z-JiSmLuxOC8jFN{@=UOWkw8gY;m&^L*HPrf~jhzDX~`}F{j1PX@R zy1%W-#xRc+dz^Z`;~a_b=XV7eu$p5q1w&iTOkbE0Mg?g(G4s7c)FTeqoK<$gPH}{(4vRLhCW-JoJ%D%AS~+13? zpJ6+jkmz8TMhJr`)iK+p78xg{K(Ohd25&%uT4%33GkM>4@jV~`FF*|#EGqG$^(8C3 zUT8Xm7=T0{B*4afzwhd-n%NX*(F^Q&jKDl!Iq`%IVcdM2F#jD$IQc^nU~Aptdnt2H z#V96wDev74CiB8vhxku^jt{nU_ z!}c6EYbjy6iF+zzA{=rnC5#wY7vB+YH>si3d_D*@x(_#x&2MEuEe?DjFnU z0Zk+5L4X8vZuiG9&spM^#fW$a-JXC%3?wsG*Axt$VS=E8${!Q)5EeZO60~S`UTVug+1v3TF$Ar_LoR|u5+wV=JN@-?22Z3A z`GZE7){kw!?@a!}02PZm2!lld!UTW>!+id-)Tek(`fdS!7r27{90Pl-cNA3~t6mUR zIR#;$PDYR41`;g6o-QTD*%CQ|0{m;>iY#0?0DH8xQ&sEm2!BZzLN_88DnNo->xjH< zQMZzz2=E_=E6|q4NI?L!sJ&M44*7}_83@Di>py4>Z4Bn=A9Tag2$DI#hFZ_%&$@5* zX&dG#w3(>&Lry%co^zcZ7+`3gfLKiF`i+N7}Vg_oV=S;+qXRz;E%>Krw_jHdrHT$)ANUva1YMrz(3GBf3SfOyLIk99)CzC=FjwK=Gpo~NA?37Y5;5z97)kk zMu|XYRSHNj=fDO5?!i9P#q{votn;0BW*Q5Fgi{*%K{51rbJ>Q?jk6q#WsTkJO_1a2MP9fI;7}}JN2(`Atc~e+I$j{>^@TX+hcd{o1JCXZ+ck2 zEddF7$ik_#{wk}Tu>5e+(;b^W-w`QxF6>+s8WP^=st94AZNx_SB1q6f;tJ`N>P8M& zvQW2iFrop85=g#X7}s%?KEsnE0qgjj8M~ODTlydw!AHtxA?9i*R-?qDB948(K(|v39YfCGM^$w&+ zzw6rz3mlY&s}$EH67lF^db)47k3-Q%tu^lK47#=W@I0Odf9O+iv?gAV41S>hMfBY4 z4Tt7K7_7;mWc{j}48N%fR*FV^!7QquzbBn<#hkHTQe_Y4qXa8?ZN4P~*&5gvA91WX z>yb&YvAy?~elL+y=u_zM^#>=wI&1gp994Du9i|Y5Q&%a01oL!a|6_dnuN5b8(gQii z{W`}_y#O7w&aYY%@ep|$fXyFldbTe<9*-N{my=Rp^9XXMLF3GKLFTT|>5@hzkf3!S z=XOFD8Y-9=r-iLQzNEN+Spr(!l4RD10RrZ>X3yXK|i3((RI8olvfH8 zPQ7y&B*><&HRao9R~hWtaZ3GmXa_0)r000j<;P)wp{`k}LR z%-y@wRkm`l?4)l7l4(X=X z2_Di-?-sPyhG9MwhnVZ4)2inldp%l!AG@&7J>c_lX9WksKJqI<+4ct9F89@OmN}J>rRc1n|2MJ*vFBUzN?ib8DnX+6izAfP) z1>+(5uz&PX&;jNgq1uxI>v$#|cV^UQw}A~f?ZWRG%;(exe`JHPE6lVhvr{^-Q^oT9 z!K$^uHV(!FQ0T6J1an(7@8-?%lq@U@Q05`AU0UauU1Kfeui9Q*^Dl@#J zD?^leG&T!*-FU~$#z||W*87C2SKn?*J;(~1>1aCYr)I$z*y_3M66BViLvl=^PH*BHWJ&*6;B zKxwyiTb5J!vD<)bvq1vIJLcGr)^{Qb6>2;Q3nbX0&&X(NGtarz$&;jl1PWw~Q1ER} z=8g9PJV_xZ%n>z;oA%520X#`1M`HM9se`!Ws#`os8%V$8-zi2o@IQ~d z6T_3N014Va;>j(YG2gC!<4JNjVG@EeM?OfHD#nvEgF{F|n1>I~Z_U>|XGGzL!Uz)! zx_sY2f-P-X-|3sIUiudEB$67$>PXB?)0-MY6s7j^BqKnAVFrEqqBFB%ES)DA4-)JR zW)IdrUDZUJ%ab^OWB^FCCuJ-+AtV90z}3Km6OWjjMN@V`=oOwMj1x~WUz_!aCINGv zBnc##9>yu-efmPaFL;t{PMG^~URF9A>n`#nhe3iG?8uE?)Yeb!HcwIt66mDFP} zQ;$aQB)`srN@9#|ig?57}4rBP1|7gH;u9TX_a zNqT7@K@G<1*g7i2HsELqD;|iao1Ty%tRX-c%t z7dNlBnFtclSr?R}`M5I|Ly9Y(#%kLQoOc%_oSteo<2hYeGo^ck;QZv;I6nQy3Kcm2 z2CV}2Haz`l!9ff^`WmOvR@9p%*gA9C;4;un26S7y{jk&bs7fbZJoO+E1xe?3#)9zmv!4Xwyco_N>*1G!0^pe@n3I;XTr}`(f-XpmpicqTp|b)* z=t1FpHF_UjiX9n62MOc>ve8)~!3-vgFHHT>;K&P?a6S>6K#+WecyO#KU$$QOL4098 z5f3pxDgqTf=BY08$oq;)A8(Lw#?D?0KTj%r=t=wqLtNq53>HYRM+~<9yjhm;~aA7}QCb8~`)3wsw5C-!HEXw_^%-Va2 z5*<7D3xI?(hhhw2pkj`3R+w^2>Fp8lUaluH>7g*vgwaPowP{oBT%n%`8bB!UHwc4y z>epfPdD^0xg9$cR$@t@38mFh~3t=!9WF-%siCE{y9*Glea3n~u{3wUo6b8xI;Jh*n z*--00%tmnXXC)_$)4j(U(n_`wL>Qth^n+PVWIIy6EOnCiel?I_DFLlL>GS|9gT;4G z^}5=MDXr#27{U*vAq;rNoQr9vn>f7Js=<)R=~6F-541e`W1S&>}Yym>po1789SxEqlbFrrF%FC@MHEM<4$vSE-W@l zPYA54M?YXMv2=YW=-#Jwo~Lu3VM}WWWgE0soXGa7&FSWss!z@tM1&z)nQqzzh=}{u zAugLFe*v)Ie7waZHk*6#uu|*I@>qhg&4PITs2fpJbT8??E%9Zs4Y=#*r8~!J*G*y1 z5@7EAVOEz@!)}|*u9YoIXte7i-{UF|`VQy@vw2kTQ}48TpVMMTg)v&*VMGI2;$Fe~9_3ypg%&F|&=I;_o8{)~W815I6{5MxhL?_s7A~YaYB9Flx zsGD9YE#l(V9H?RU!4;gDy$E}(->fgp-?UU#1HBhZ7PKy@qj+KijB20 zlJB}(J3vk}BBC{ap&*R9c(ty??n(c@btxVx9@+M~x~ziO<$VLv7RjBn)6oNeqq#HPJ?Y<=CEusHhOujZB2T+d h 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 literal 0 HcmV?d00001 diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..1c6facd --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c8b3a22 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.330.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^20.11.17", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.1.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7a1efd8 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,5 @@ +function App() { + return
Hello React
; +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..942501b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,60 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 346.8 77.2% 49.8%; + --radius: 0.5rem; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 0 0% 95%; + --card: 24 9.8% 10%; + --card-foreground: 0 0% 95%; + --popover: 0 0% 9%; + --popover-foreground: 0 0% 95%; + --primary: 346.8 77.2% 49.8%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 15%; + --muted-foreground: 240 5% 64.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 346.8 77.2% 49.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..3d7150d --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..7cb7e37 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,77 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..96b13eb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }], +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..112c417 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import path from "path"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From 071aa9614be7e924e7b93c46e81cab10b3bc4395 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Tue, 13 Feb 2024 13:12:04 +0100 Subject: [PATCH 02/17] feat/router + basic routes added --- frontend/bun.lockb | Bin 111077 -> 116227 bytes frontend/index.html | 2 +- frontend/package.json | 3 ++ frontend/src/App.tsx | 5 --- frontend/src/main.tsx | 34 +++++++++----- frontend/src/routeTree.gen.ts | 56 ++++++++++++++++++++++++ frontend/src/routes/__root.tsx | 31 +++++++++++++ frontend/src/routes/attributes.lazy.tsx | 9 ++++ frontend/src/routes/index.lazy.tsx | 13 ++++++ frontend/vite.config.ts | 3 +- 10 files changed, 139 insertions(+), 17 deletions(-) delete mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/attributes.lazy.tsx create mode 100644 frontend/src/routes/index.lazy.tsx diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 81b36157bdca6e901d18396e348ee1997a20ae56..a3f2b2513f5f6a503b433653f381c2d2a9ffc157 100755 GIT binary patch delta 21143 zcmeHvcUToyxBl!SM>r}9Sm+2=6zRQq4l1H@M6ezK(I6ZZML<9!8U-~fCediNaTI&+ zl^A1z1qr7nrj%`an=u5Q_Ifr6y?p@btX2e6x@;W3hcvgoP|D7-Y`Q98f4UQh0Ar zYbeK669ik(fmu2IsSfMvvi{J_?9AM>{QN=Sb;!S^m8qMhAk?8sKx>0uQ{=U*1i=RU zYsks&Q}8HPRD2wX8W1c6M|3v$TA)E->_JI@U=jxxvvmRYQ~_n{G4~Q1tAeVL-lOcNac%)A45P2 z>G}EQ{B%KBi45X%bMgxy6Bw*hfjr2`V0Kz|PG*K6%mh!pT$naAi#jS6JT>?OMQ?ut zx!il;$=+bJmdfW+ujA+izMfEoo`wR&BS}$APz>C4kPY_FOG`KB3xXN$lYzCMWbh>@ zRj5`xII|#=3}@zhOM~Z*ldK=9*be|D{h{W(f#v`~kmT(l*nxN=ip2xc@(YH+K%RMc zJ~eW*i|k>Pt2{zVK&h+NDEb*$Bd9*XQ^QNEx#{+}$?YoU`>NR&IiNN&+69w=T6@@C zrgk2((e{v2Yx2x#=>;&d5po)a9U95A&&nT3m3e?iVBYXr@MvdoU=u-r z!^NX=&^H+8LT;Y9pdizncLy9IReT;4T~ho*Q+a}&2PMNtK&gRcpfr#in#*1?P#Rzv zX$5BQ0onN@G7HRPIWH$GYk008xV01n1f*C4N)5QtLgr6^I#R!`L&6;y(?AiL;w(@& zR~!#YBh*KsHcI{jPg#Brlnm|wrIEfKlm?(VzyI*etPH{3Ul1_3i>*Ov06z1hfg}|F z3`+8Ei7!H8FA}7%LCGkk4Dj!RQY3N}J_(dOZKufnK&hOIl3xRq3_tdj%YP3_9EF4HpbHc-nlkI1ITu2%9VDReq0^<0M8 zoa>#LFWg4CI^h4h^gWM~?OjDa_2>>zbY}73d~a!TrsWOHP0PzKGOt4hb@gIUnsJ!} zvvcy$sk6cBz|*uz%gPsK8DxGyW_E@*`r0{K*0)h;u6I^WI)(*k=FrRn!KRIzUjjSi z&|>g3Q}Z&1=A{h~1bHpT0^h4GFRk9J$k zyv%HFuyoY8cI3&H&5G`Kl(Wu*Izh!3sx%gCL21xF4wDC=tx+D7cfgZtmq5wY*N{^a z9)VJo>pRPI5hxAX$auL42cYi=emN-For-3HFDf2~1Qm=+kbO3QQr`rElH5t5JW(EW zSfkUj(lX4Gz*7ZzpyXR7>^Oin=_Xg$t-I_{m_i$Y(wNkNQbX=Q&y}X>w@8p+CnyD|>XU~KyM{CRgSC+-NHDC2jQAO)drp3H$v*)F!MW#&$-Sd%&Sr9$wN#5M%mrFX8 z^|LP2vK(G+XJ8fFv95tV;z>B?@Hsf|<>fd(=8p9Yn)Vhvxt^YV%;(fIutU5YqH0?1 zR9~-YujR@0^=u)ZQ{SL*sLm_E4C9Us3~V(|!udx&r-4D^ZOJPd=-D{#Xiw#n>U8b`8}H1cwErxH`YqAXK+r;k3lDo4nk?pf#e~ zd-3_Mk-Ei5=_NZ<@~XmF@N4x#wJp(gZTaN}k!%EabTY6lJjuzRy8|g%x8!L~p*l|t zoHuo`4DRSG={Xy8_mL9=J%Oh+2-O5x^9pCZE)N47^Rl=a_oyGLI}MI1w~$QL#zICM zoItaOg9`wsksMt4hNEVdgTq3AJaC#;b$F?(UYA`*5W*moOlwzwi{P&HBiZ*n$<4s> z`5c_fdAXZG*9v1QPSwp#tq(a8dlST_pRCC%GH6 zE_DSVh~lD4MG7mQQg$JC^e|{F>TxG~y|x=>SdbLrNk~y3r0!tHxML%Owl=OVZMmy` zq%ILD3ZPUKE9K>l47#r&>jIet`L1zkz>^#6wVg57BV>a}Q4#b~gHY`$a8fVnZ0!XB z>t=BsDVzo1-Wb+x0*Bd$kfCRF--E+!r@+I4A11k;ENiooilG$xy1c1D_XIqRD+C&) z{IRG6fkT8b=5oN1eM@PSZ3jnAN~26y-Kpw$j8-@ewTn&UbDA4;pFl zPbeIma0}I0V7pV97X^--mmJj107t_M9ICzeM$^M9R96?PpAYi1QV83C!-W+bX^sac zTe*gTc$SxY8#GNlxRZ}wSKxuoEfnn}#e?7iRSv$E`xtbI*oQ?xhIqP$vJZHYFD)2z zd=0uLjn$r~`Pu_qZ|Di!15T}blZR5)m%y2j#{?|D=qoRN+Rn;4zKI~<3iB7~B1nB9 ztp?o&{F)DR9d)%Jl%fJ5x-wQaVBb^_y1p?m!%mhCg4Fm|sqG_3r3nQKn8AWIpJCUW zpN`U5HBfRe!oEKsu$>t-KCZ%2>l`5rD zTH)44O6^6eAHVD$W#KI)U~Vi@vf9NrvIq#Y)^4CY zYO(eTQbE$y&90s5ya#N>DIE0CGvmQg8>B0rW^X%Q5u?}rp$Mx{BsJkNJh{DImxGYS z%5A`$-U^O9K|rB$JBB;O>UCbY@KK|2{izqK8Q7ke#_Bc8+VcwJJwzV$BLd+Ws!53D z$sP2%@i2&x&^88Z{c&)lB5izh+79aFkuLu|!G%c|-l<4o7E#AG4%K}L4)YuB!0^!B zev?O>7s7&Ol6Cl<1CDw^>Lty}j=Z8122v+Eu(-%07-8U)QG#_j7aU!mMR#rTN1#@SG6JFdP*F{Yi}GcHR^TVSXgPrmG(}Wu|{d;ltD-iLkKH&o#ictv|G}> z1CH_#b=1CH;p9Q}3>^AX8QU@O>YPAZv}3__l9sElkm?341Q63Ks0(h{BrXlTaiI$@ z?V{Huq1Duja0(Gx2#$Im1sa8FE+_Eh1ihw7A}>wQ^V!{M@gEa{be3>}f+9~qLsy=h zsMn0|%1aaVy0gfm5Mh*}$=;|NYY!Sp!5oR}mFW$4>Q7v=9%}eVb2vCTe8e3Cm-?n$ zb7WNJy@woBD{LAWCwKDe0(6si7DNl0F8SbPy%| z-nhe;GSHXOL6kBOXz6$xrG{bnNC#2UPXnl;{s0|BiBBg6$J;2C8(@KxhL17Z^N-9{ zMI;&m&{36=sVso#P=F4i)RY`za8#uv9|n-U0)URUQL25Ulq*pze~N$%jFLE+lHyo^ z8i0^V$G@T)J~Y+7D``&vh`ukCc{`>2iAp|E;wJ$#c0L5?Aj`qLW=HE%HU)A+80sw3gUp303Ae$ zUquWKqQtKTsJgWP9aSlXdLuyNeJeo6+bHR6mvSX4Poy0XPz5`In!sV84sZ^j z(NMJldsKKws`meYs-yAWQ6WY2SD+bCml%ao7da}tmJ%BN>pKm3t-)3V9p$)6Qt!dO z{#3|aQGOexEc)*S9YnE|50*z&Q*e~gOvxom$>uoG45$Av67Pi*HOZeCg;Fv=;j2;_ zeL;$xC?$gxz6zD2T16l!hA4_esR8s)7&?ejGD6{rQbkdUytN`HN=dy!4T_v7>97d8bEUDcMWmt5VYIt>h&5qf|N*`PQH#6utiiD)-9U3{m&Lhl;4;u}XzRX$_nNN}|aMEm7zc z65=3A9}fQYc-3{+;h#$6Qu@9``)GMD;~ zYtBDeG5$)#$HFG>r-Sc3&z;dSz{f zd-E?g<=pI0oVatyM8l!)s~CO{Y4xz^;BDRXUUbWl2lLv@&-&T#dhZ%;n%i65+gy)2 zKRv9-Vf>KAOFr6Oty>5?H7>Nr+x7!J6e~F^thYzbZOwxoE>$SZs;|AxOgTfcIx4jMg&G;Qy&G7@G9^cKE3F5gDoDj?%xWTu^~98f2|)sd2LMON5>3F=`{Cs z(1vC=e>2A&_{y!-n#Xn*dX`01F`hFmUDPxFvvy5S!I0YGajI2N zi_c3!xnuS1w?KRfx#@4GIPEjNC6>S@14)3WmYey{QJv&Ugq8g1J2BI;m*-X^8t>w(&VGq)9u zI`sYWLEas`jKMy^_P1+P4e|R`4UZjObJwk5^L&bWS)Z)YbivHXno~No9_k|KPrdT~ z?RdzO_4T~kcD}l$UF*V{PwsqQ%l=%$xF7rf>iz6Ro1FQ{UQToYQzJ~9PxatIvpTa$ zeB3M(o6O5`F5&*7iA~{CaQ=W_!}&uVKHJ1T;xllb%5UO4jklR&qMy{@Je@zlc?R!P zYGR-8#W-{R4Ck4=>s%B2l&{8l7H9KJOyo&8&*qzPp2KzXP57xr8qRb1cAV#N+XW^z zpJ(E{fbYW@zvWnHVvG1NoEP(>I4|KIi%e`OFT{BnKZEmf-eR$dt>EKuUdhXGUd8>F znD7gpDLAj;*Kl6T!m>Ix&<%2%&2@sVq-x%EmT+s2btnz-jWYrY%YcCK4x;upXTU1elD z`F3z6>#e!#Y9rgtGgq5<*amBU65N;EagB-p0&dJ2BiqN1f}6e3ntQJ`vID$ut%-Ns zWX&&wJH%V8Gx4Y3rmi#M7cu4FR&BQCt=AjbQ9fn8iTB=O&3^@VoQH2PajP}wz@=_BvU7a(X889x{M%xr z-`H${f7{?6xN@#5gMZ+LmKoVaz8zf27x3>hBfHEqKZAeU;UBoG+;J=X12<-?kzM0Q z!Oh+Q|2{Xe>%8!D__q`OfxE$5Y=eK`rfxH`pLsdBRlDHd7e@R(YRVVzZ#Vn{cbkWA zhktwE-*zMWmEQ#S1-SSfMs|77-(L8)(}>@LJp*?HT2j9NO#9naQFHP(v&&2r^--q*S?zq>a z!BgA3y}dMy@naww#vASHrK!gFC=d(AzX8!Q-g190{`4?>+i%oZGG4L2m&S_mfCIfW zI>tW$sloUUAT=3}IM_>5i}6nm_F~rj=0Ovy&D$I@u{wMX&Nlo3&bGYMVfb|%ejPTl zy8IcqBj8fMGP3%7^;hug1pGQ;WcEDi2>dz;zrZRI=DZwd zPwxMr{H{1K{Gi3cI;FPq zn^RCYdFePwdFQiM+FiDI1P#f@lH?8~9qU0ClJ1fu@tl?Rqx$&8tz?l;|29z)pEz%& z9p!|glK4$UnBlhNlc5zwTGr~Xw@)>ZtKBA*omXzU)4P9ea7E8ZNYb4^3$Y;mEFD6l4&2de$%K$cJ;_`S>n}Prj5l@9^Ic?#b5kj zr9FcG&ywo~Qf;LEAT>QJ(`PU)F4PY7$EmWctj&!bQt)`I8&;YQNvPG0p(U%zUi{<{ zT9$ZY9pipi&eRB_=eanJv=W=Uv8bX~ujPYe?#QBC#8^61o@yH@7n2?$Q<5$i&XN{> zV6K$20Pm*A8Y?n-REHnsNJkSzMh~?nAPu`kg8Ceq9=%DAvq9Bo(N;*efFd2umBRGQ z6|s~a)p;ti8sH589i&f1=pPj^B)~z>vZ-;k0D7uMhmWL({SQ5C>;O;!UkJ%tj63?b zuOy=f-}saM<01bqOMduGdNzPj(W3|6B%|o108|;>BT`vA0RKsrY=kPZy5QsK=_45l zQ-n`jk-8vU@)w^sWTU!bC`(T#>AxQIWRsqDZU8m{^rVynGl5TmS%3)42Ic^zz+7M+ z@Gd}II8eOl!JKt;AAb+HFShVt4U3*3`5gEIcmccwUIDKGYCi)sKsCSuumr3C9Z&

SbLJNRKju+4h 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 X$o%cqTWl0mlit{9ZEG}&9SHp|@VfCk delta 17743 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 XOl~xa{k}WnWwx)y?rv|g{n7sq4Iax8 diff --git a/frontend/index.html b/frontend/index.html index e4b78ea..990e82d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ Vite + React + TS -

+
diff --git a/frontend/package.json b/frontend/package.json index c8b3a22..8a13252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-router": "^1.16.0", + "@tanstack/router-devtools": "^1.16.0", + "@tanstack/router-vite-plugin": "^1.16.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.330.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 7a1efd8..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function App() { - return
Hello React
; -} - -export default App; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3d7150d..37e614f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,24 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; + +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +const rootElement = document.getElementById("app")!; +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + , + ); +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..9ef2d2d --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,56 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file is auto-generated by TanStack Router + +import { createFileRoute } from '@tanstack/react-router' + +// Import Routes + +import { Route as rootRoute } from './routes/__root' + +// Create Virtual Routes + +const AttributesLazyImport = createFileRoute('/attributes')() +const IndexLazyImport = createFileRoute('/')() + +// Create/Update Routes + +const AttributesLazyRoute = AttributesLazyImport.update({ + path: '/attributes', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/attributes.lazy').then((d) => d.Route)) + +const IndexLazyRoute = IndexLazyImport.update({ + path: '/', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + preLoaderRoute: typeof IndexLazyImport + parentRoute: typeof rootRoute + } + '/attributes': { + preLoaderRoute: typeof AttributesLazyImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export const routeTree = rootRoute.addChildren([ + IndexLazyRoute, + AttributesLazyRoute, +]) + +/* prettier-ignore-end */ diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..842d87a --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,31 @@ +import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; +import React, { Suspense } from "react"; + +const TanStackRouterDevtools = + process.env.NODE_ENV === "production" + ? () => null + : React.lazy(() => + import("@tanstack/router-devtools").then((res) => ({ + default: res.TanStackRouterDevtools, + })), + ); + +export const Route = createRootRoute({ + component: () => ( + <> +
+ + Home + {" "} + + Attributes + +
+
+ + + + + + ), +}); diff --git a/frontend/src/routes/attributes.lazy.tsx b/frontend/src/routes/attributes.lazy.tsx new file mode 100644 index 0000000..1d912de --- /dev/null +++ b/frontend/src/routes/attributes.lazy.tsx @@ -0,0 +1,9 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; + +export const Route = createLazyFileRoute("/attributes")({ + component: Attributes, +}); + +function Attributes() { + return
Hello from attributes!
; +} diff --git a/frontend/src/routes/index.lazy.tsx b/frontend/src/routes/index.lazy.tsx new file mode 100644 index 0000000..b1e03d1 --- /dev/null +++ b/frontend/src/routes/index.lazy.tsx @@ -0,0 +1,13 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; + +export const Route = createLazyFileRoute("/")({ + component: Index, +}); + +function Index() { + return ( +
+

Welcome Home!

+
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 112c417..6177c6b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,10 @@ import path from "path"; import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "vite"; +import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; export default defineConfig({ - plugins: [react()], + plugins: [react(), TanStackRouterVite()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), From 2eb0ec687864285a283ab8f10b8e95f7b2185d40 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Tue, 13 Feb 2024 13:50:49 +0100 Subject: [PATCH 03/17] feat/root navigation menu component added --- frontend/bun.lockb | Bin 116227 -> 125324 bytes frontend/package.json | 1 + .../navigation-menu/RootNavigationMenu.tsx | 54 +++++++++ .../src/components/navigation-menu/index.ts | 1 + .../src/components/ui/navigation-menu.tsx | 109 ++++++++++++++++++ frontend/src/routes/__root.tsx | 15 +-- 6 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/navigation-menu/RootNavigationMenu.tsx create mode 100644 frontend/src/components/navigation-menu/index.ts create mode 100644 frontend/src/components/ui/navigation-menu.tsx diff --git a/frontend/bun.lockb b/frontend/bun.lockb index a3f2b2513f5f6a503b433653f381c2d2a9ffc157..4b7104e652e457cba52deb99c9f191a3f27a538a 100755 GIT binary patch delta 25099 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 delta 18550 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 I#&$*i7l#%&lK=n! diff --git a/frontend/package.json b/frontend/package.json index 8a13252..4d6f19c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-navigation-menu": "^1.1.4", "@tanstack/react-router": "^1.16.0", "@tanstack/router-devtools": "^1.16.0", "@tanstack/router-vite-plugin": "^1.16.1", diff --git a/frontend/src/components/navigation-menu/RootNavigationMenu.tsx b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx new file mode 100644 index 0000000..7cd0388 --- /dev/null +++ b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx @@ -0,0 +1,54 @@ +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { Link } from "@tanstack/react-router"; +import { HomeIcon, ListIcon } from "lucide-react"; +import { ReactNode } from "react"; + +type NavigationConfig = { + title: string; + route: string; + icon?: ReactNode; +}; + +const NAVIGATION_CONFIG: NavigationConfig[] = [ + { + title: "Home", + route: "/", + icon: , + }, + { + title: "Attributes", + route: "/attributes", + icon: , + }, +] as const; + +export const RootNavigationMenu = () => { + return ( +
+ + + {NAVIGATION_CONFIG.map(({ title, route, icon }) => ( + + + + {icon} + {title} + + + + + ))} + + +
+ ); +}; diff --git a/frontend/src/components/navigation-menu/index.ts b/frontend/src/components/navigation-menu/index.ts new file mode 100644 index 0000000..74d64d4 --- /dev/null +++ b/frontend/src/components/navigation-menu/index.ts @@ -0,0 +1 @@ +export * from "./RootNavigationMenu"; diff --git a/frontend/src/components/ui/navigation-menu.tsx b/frontend/src/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..6cc28bc --- /dev/null +++ b/frontend/src/components/ui/navigation-menu.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; +import { cva } from "class-variance-authority"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)); +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; + +const NavigationMenuItem = NavigationMenuPrimitive.Item; + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50", +); + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)); +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; + +const NavigationMenuLink = NavigationMenuPrimitive.Link; + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)); +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName; + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuViewport, +}; diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 842d87a..caafc95 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,5 +1,6 @@ -import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; import React, { Suspense } from "react"; +import { RootNavigationMenu } from "@/components/navigation-menu"; const TanStackRouterDevtools = process.env.NODE_ENV === "production" @@ -13,16 +14,10 @@ const TanStackRouterDevtools = export const Route = createRootRoute({ component: () => ( <> -
- - Home - {" "} - - Attributes - + +
+
-
- From 3aade18921e2de393484361a1c0c95ec585e8b9e Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Tue, 13 Feb 2024 15:16:55 +0100 Subject: [PATCH 04/17] feat/basic impl of react query with router loaders --- frontend/bun.lockb | Bin 125324 -> 126117 bytes frontend/package.json | 1 + .../navigation-menu/RootNavigationMenu.tsx | 11 ++++----- .../src/components/ui/navigation-menu.tsx | 3 --- frontend/src/main.tsx | 8 +++++-- .../src/pages/attributes/AttributesPage.tsx | 14 ++++++++++++ frontend/src/pages/attributes/api.ts | 10 +++++++++ frontend/src/pages/attributes/index.ts | 2 ++ frontend/src/pages/index.ts | 1 + frontend/src/react-query/client.ts | 3 +++ frontend/src/react-query/index.ts | 1 + frontend/src/routeTree.gen.ts | 10 ++++----- frontend/src/routes/__root.tsx | 7 ++++-- frontend/src/routes/attributes.lazy.tsx | 9 -------- frontend/src/routes/attributes.tsx | 8 +++++++ frontend/src/types/attributes.ts | 21 ++++++++++++++++++ 16 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 frontend/src/pages/attributes/AttributesPage.tsx create mode 100644 frontend/src/pages/attributes/api.ts create mode 100644 frontend/src/pages/attributes/index.ts create mode 100644 frontend/src/pages/index.ts create mode 100644 frontend/src/react-query/client.ts create mode 100644 frontend/src/react-query/index.ts delete mode 100644 frontend/src/routes/attributes.lazy.tsx create mode 100644 frontend/src/routes/attributes.tsx create mode 100644 frontend/src/types/attributes.ts diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 4b7104e652e457cba52deb99c9f191a3f27a538a..c676e93304a548658de88584ec3986b74911e78e 100755 GIT binary patch delta 19933 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 delta 19534 zcmeHPd016d*FXEpRgQ{)B5)ZLoX80ogo~nFaK`I|6N3X@;i4!40^%ItkeZdz&UUD& zSmtb+nU$uZSxz};p*c{QQ)#}bscC+{bq28aHNUs#`TqHyr}OYTYyZ|>d+ojUbk04O zb61wvcQpG|Nm5}MOlm>qPDshI<=Ukw#ibqvi4LkVZ?ru#J#DfS zqw!vlPB2cdCP}W4qcXBaQXQ?TtM=p4Gt;wexw&(|W#r46>{vsRoWWm%tOF@&`j(E8 z!zHjcz^GK6wQC0WBzug`{RpMldGG z;gH^t6_BKlg{%*mW6#aX$hS*jbtS1GcuPoXt}i4tW3(+dYhtD(WnpBfo>K;;d_iG- zH`O2|H`ktD4<>o;r`N2CCD2ARKX4*2d(o-dA3pkDPWZSq58YnwBHF%9? zSK+RfTMSPA=ApGzKAT1z#{_UUsX!`x0S43`lQhFj&4ClbC5Iz(Y$^6!Nty*t4h}++ zLmw|SU$1yfdR{s?PS0(vOrBckCbCb`{Kr9(zj5}QQT7l?QuJM*D2E|>u`tb+n>QW~ za_kdwsga9()Ch<8sxxF2Bn{O8%|10FpX!sk^O9-}<#P?xW-a4o)jSH!sEmA$!&;!m zUh-3Au)pdv6?$q+j@_1$2OmeEr&*ZRNY$SKr~Cxikv=6OcM{bV4et2_Zvu{H7WQu< zN$8KlDOngAOmita$DWs$ZqKO&19Vv79cVEkg{+ynzV0+t9bbl|2A+VVd1MYygY<)> z`ITzRv$sym%sq|rG_S5TS7jea@_V=i4I`G(b8175K<`7tx&aA)WXyp?orR+y{UF03 zDS{@Dn45*wAj$4pYe_=zg)hPp&8usWRKZC|YRFrV6k&dPo*iwGaIw1UcvkNfkGQ#26Jm3Q{XN4@nX2(qt(l<v&Ac(i}Nml}gT7YhhY`Yl@R+g82Rrr1)la;TH8Lr*ad=%ebtj^-hb%?um+@+8M* z1x5YT4j%2c61KNjMq7f;)XUzs)*%)K$@nlF>Nc5`GTNBf^%%~aYuw{20&+6S2B{-XT zInJ+glc$C4=ZQF1<0Uxv;^jCm=Oz=CO~g5pmzXT_bVsa4w91kDo6IbRo4hRY3us~o z=&JJyPqTbjV@6`kk(a|_40;uVTv(0!dzt0kU{R_Mus<~Bz$@I%##l^@PW+r_BwNT$ zJ{ESIC;C|AS{Mzgp$514nC0#otIqxVMsSm_V&!X*oza_E@P!h$q2>X+9J*rYum-7S z53_s|jB0jJ{Itf3Csu=(qTR29g@73-GWqi=hBn><>!#*`S+Uh%1Ja^-87x9&9%kb{ zu=d>7Bhv8LnJ4*0vDbKspT%&`g`e?@lC2ofp1Q5P5RAMs?(ZAHOZ+YJK+JTAR+p1NdsVl*# zOO&BuSGlQ)#n>7_@72uBcgz$-8|55X+RLZ1YfvAj&J zEpmDTz1M8M7(Fm`YS|F?b&VOg&Ce`1Zun&05HN}xc~~Huz-XF*p-&!ETKcy(%OTi; z0+DA_ruhi47=@8#DVXY|!q;s0)sLTP6UDl4Q;0hf;>$8}mgWQO1lWk~Dz76%xsI zaZ{+p@JAD#6dJ|Cc}b|nIH##3S@~O`k=XZ4ZLwyW={;PE5MBqP5STJmz5%1vFL4AU zcf$=NjWTLRN6Y{ltXLv+!<_)WC@jht_?#pS<-R_VvR@0NR7Y?-4UA?i++xmO1nZ`( zpk^&~ClzG%Cw2mCgi?m`LRv}Em`b(;Y%fA*FaQ7bh`|s)Z9ZzsdY5L zf{CXR@>HZ~hM^Mz%!aaXz9=fnxEIsEEBCc@aA-%LYTk;9H1L>VimLBk?O6adY~dzSBz9&MRyUY-uzrl2M5d%)kz*ws@bPVsqPFN zb)6Nd-bzD|=+4ifkw{=_qLD44bgNvX)Jk_(>ST-NO-@1zt}zr?(}p)J{7jc9#<;1g zMP7z=NmI)eY3m-j0O@D8tgEbTA(7xEIW2q8M+gfh%UUm zn?-&FI@+LM?_)N82G)(A3yPF$ViuuqXwOw`yIbUi&}rVVJdStaNj;)uSM2XOFvK!w z6yXR$-js`}@e8mB<>K@hDHMh`nxvhv;URFfPPqt-woxN3Pvcgw_S6Ey&2BuYXA}$K zB|UMu!Ob)|#{9%KxebgQDEo?hM`PHVkr#@-pbb*B91BL}swtb`dN8$O+I6mh;ZMB~ z${k|W;9!X+P6DGwA$(j=)`L-}*5DPW;WscUrz}mQr6=-~F3UoS$}nzwF2WInnt{s) z+?~}JieOagS=DAJ*H}ZGm7nPoCBLL8tI>it9JKPJzELu*S9Az91KU$KFbW3OJeW-D z#l8AP$vdD_r%Q#q*>JBHU(_#(h4J!!7I|iGbqvw7EzE|Cy?Ihxlw1$HN;I6{z6tx^ zP%s*1tKJG>WSio83)TvGhz?GsRpt@f0Y+nu3J^K@ zBQNSDAcB_rh*dT>bhsE{h ziv~r>JNru#^wcnu+2A~Ydtrri8K8y?CA_>1hR`T3T-a}EdFXardU_62E!BzjA{Z8s zI?(c0VCsrMTa3=AI#$`F5|Bcz)P-1M`@oVEX2Wo#59W&!qU39X)nPQ zU1eThFf$lJZe^BV0K;BN4D+}QOkH@T&2f27mLzm7mLah#U^L@Z7KrN{j5YQq7)@Pe z|B^3(wFSdGLr<7&>gcGOzyL5>De5@R1EXH5rECHRz;Nx-=JoGj>MX}Bk~@slmji7j z`Cw77RPUfZ0K+(HZK;{^BwGho*=pv(6GvKPYpSZN!TlSXYOUa3*6BTr1V7-pvNMXBuzOp@~1Q(}-_fIAQb)Bw=e z%JHPE1`TGcVvpWeWCz7c*)o*WliUbR3}66oAyAGdWP#$K3)IvA?C8otk_uoGRt}QY z0qi{zH2^^?_Lw%xL6Y~0RVtpK89YroAaAmk|D+_xQ#CzFvZJV|0fhh^h06J9vYy(q8JYn}#(R(Va3#fT zO{+*gY`h1bHp;QU2<=N+4oRx$Wq=y8K;y4M(m|5+uK{Go0Xm)~9g)9Su~H?KTLO@L zOSx%Q6N=+yNYnA8B*WzZ$x?t0k~C!R0Hj~3$#)^?AW8aFnp_Pj@o}RY7Etro6T|VO zq<-E6(0u<8pyO$h>^@O)RY|pM2T1M!Fapw1fc$)=$+M7D?gI5*HQqYcqk!m700nUq zr~%vsNdEw+Non{b?g&ZlWt_;qwkDk+WpGc(T9Az)ogqUY>3D{u8TCwqzn$-a60usv z|GQM{{~HCA_Wa-37d)e-l+_cbW{}yM9Z4F|e2tT&nKTKKRFgIRvm}{J!HLRozX?s+ zK}yrLg3pp<^1Nn8(!i&W^`OZh&A^HAx5j$3qj@oh801`>sIjjQ(Ih1oX#7cO;6G0Y z;I;D|3BhE*H7kU|Ndq62;b9`hVw^}Wp_3*J{N)Ufb~Jb&;e2VWg&F*QEYWp1d zPLL-xyJtvsRzJf4Zl3LkhOHbGQN?Gq3Q5u?`aLA6e$eD4P5wwq96vJDPY!<46eKD6 zv&Ns4RKZQ?DYA!L57}O)P$rSbJFboL(=9-hIKRpB(Uqz1+SRAoVDoalH`lA|sF$*uq$B&k4mA~;A= z&-DhV0sR27|C={DX>?Pm93;t6B0!Fk06IvL{%JQjluvqUSTYeDB*}gxKz>qye{OW7 zLb}XSKh2>2z(Mk#8y$4QKQ}t)gePu%)Ghf51+xES@VEItH#*d1|J>-%c8r5=c>cN3 zQM%&q-1z+ezR_{SwTH&P&X)2?r&{x2tE~LqyH0%TDl41A+3I+{dX*EmueP#zd^1?W zYA0TQjg`&kwl(qGag7t-2lg^|T^r9o0-Lbbiti!Iz(%ii;!W3CSur2KE}na?bK+lt zaqho9p6>^nz21s%&X0plTJOZeHdvY9MH}MTo4g$75+1xUo-N|@a9+$Sa9+aOy%*2k z;>9>G|MSF=T)3-iD#>M zBF=00W}Me@`Ga`2j@xiv&p*a_19#mTkN-qS$N4>8hVv$F`Y@ht=HqdGpC7<^3-{j^ z&pzOjao)<0@^dSz;J3iu{2cLpVP)U*(k~F-7l?1a zm0jX-`w<`5R)mG#}VLh1bD)V ze@-bqfdEe+z>`*HKvjvZ)E|TT|iXl5!D4NYtA==C0syM7p<%%w_QY37ZDX$ zYwlWssK6#vSXmoh1~$3^QGI7+!F>F8i0V5;1s2NvzeiMHv%k0E`=jGvlfFk(KUnb( zK}A0xsvi&)SUVnk3Bv}q@RAk(qfh}h_Y&gz(Tc~_#Xn-$estP$=SK@OZo~&Imo4M( zdb2J4u6Qzj%+0}RN*(nds%AmD@bPyKk(T$m?r1!V2adI%S)gcsf@YK(Y@r#TX!`x+ zXk1sHHc;}&`(5d$s1N__Xe_~}#9E5FvLbebyZ&<2c%zBl1Qhs1Dd6*~qiJ~?^*^ZS z^ML+8nh&6JhLeVj554VZ{4iYiiIN}i5tki!hr24?yD^h5^V zq$nTY+i5y_S{V({(O%Qh6G{4_ui_lB^ zJJMN_e(59@S=ftS^o)a^d(;AI1N6+~7vL5X;nA#7@F}EeXz1w?J$c#&d<1+9d;)9- z=wV_npf}J*%!_8;@?a$6fdsK3ngz(INZNrkaW0y9)TjU9&IGc695f~m@Di>u$ZCXS zV*pEm{?%1^NZDL;jA7pH^cdI+@CJ4u>r-H-B+_D7NXJb`ZwB56wgBsaHNZQ-3g8jS z`~fTm==sulfF68(0qhs$F)W~9Cz88>-N1U_J>VVSWfXq}SdMflumYf);hWL^iYNauLExYCBPiuMW6_n z222L>flMF^NC(CMV}T%XpcC`1NuLF2!L()K$4;!jQ)8r?0?mK`(X}&cS+E?|^*|p( zeh6#>)&lE*l>j|?qo;0{f$IQ0N~DLAn}K5FF96;~n!XQP3Q13GMgp^to(<5FDg@|h z@Kj(1@|FO!95YCyB4G#8fDS-Mzyd@A^w137a!Tz0dcyZU@EX#CfMTSJfSE{-14aS( z&Q)25-5}co^mKS0@FMUM@G?Npk!K-~)~1!3@hTMbtZE3*AE3ty7ol4SIS-fsbOvZ6 zpvTUXjsa*L)4J^m(7GH7!~p{UdKQL_Q(1?!?D}dt(vyu}XE?%kh>tM5e8r<@W!^pu57!Z9Ys&QqBoc(4Naa`0U92fu{3#T_Pzwn z2WTR_Aa37|UFgl#x1OzG}Fn2Xk?y`bekyEvCEx z(Jz+y$fTz>%n@<$@A58^)NBFJK#S5?<{>u4GNX|um|g6PWo?WlNWLlV$FleW8h};6 zZlE^ernfANfCFFv7(jz1LH-VL4|oo^3)}&21OEbU0ylu4fa}0D;2iKZ5D$C>oB~b) zCxAh~ahj4xkvIZ;27Cz|1P%au01u!H_#D^|d;#nO$aXJ4dg3Gx0d;}Hz%k%7P!4

tH5R80!_(_Nc;#~0)7Cf0p9@?!1r33L%)<8Ze-qp~h54n!c5xuj^_8RPk;N zb-`T$7r+^ahQ232bAa^jKz)FA9XEjTsSNGuZy+st^jyXpcz0d z+YOM8Y9^`cTO&;a=>oJsx;d~D>6VbKfMB2vKvzhLidJMGco3lb!GtSNZa&OV(#?hL zT%Rp88Fc5gQg?&XObrL<#^cEblO5$#M*e*2f_jl8_r8e3{okuU9@JFeTz>vUTY}cZ3Kh{$I76$cT zU>5QHSZ~%{tn9}^z4aHqf-Ubfoi@|e3&lbML(vK;N&HH7b`cYYIy1%bAcbXyd}(E-QX+^-ydV~kX|b>+GLzU7$Gq5LaSkeP{e3s@H3b(oUfzo3KfG;V z5H)U#aP5yWpNp0xPKmDlS#NLseZIx@w_Wp^y<;rQgV3lDT$#m&{)jnURKSi+5p4#b zRE*dR;jOYjSUid)~u=|QA{6z9$10VsEwAZ_J&`K;_v|G=`B@PzLPH8?bWpU z_T&*4Q7#0vAyipBM!B%MFz`Y-{WZ(kE<-aiX0atO#E?-K`U{u8d^SCM$=8QZ8d$za z9>`3xUbLeq0%fm>#Sq^5`<6@BzINTc{)yRe6oKwQd@aRM7=-EXTRzHQo3a~qIRk^R zz+f^s1A88Q8rYVmZ5A0kwJVgG)PUI@@hREquaYKy9^U_2&-*{2Li8wA*hAcc0UA&J z!^%ZgHgk7?{s&=+$6OjKa^o?VqC{CdvSS546PvvCH%RZh+Q zp0FmdBIYE<4`XhAXkpbWq8)$Tz4@R^*l4<84L~U_(&GM|hK{CBlZV&@lZ)&iSdY zZ{Bg{zy+A2S7|EhuY~SuwfIcrAkUsk`>=9o>v#)A)jcDWEfU6|Sk(XNi2PucNkhcA zp{Q_`m_v5@tEfv~*xq^dqdMYoVYm@ZYPPx!w|g^lZh=8^M|2R|9V$aW3L`W zbLx`VJc8A47>dpg_E)#V+G~#;Ywxgxx&gBa^IbY377Ryo&WM2Fs7+A8n{*I_^)?VwXoMd+c!}O#HAn9+3UVhPgC`)cuU(G>X(zfSBbSm=<)88ju zS|{W9+@9NCt17F%aXMvCT8-jl*O#hts)@xmM54d4`k>k9JL9Kx!&a|X8!nE*z|g0; z*pbdWee`!-y|x~xK6QWGqbkEW%|(Ne%+s);x%eXumg?)WQem4HyB;wQdU;lr<+bJ_ zV3)6GdPSGnDekrWme_!fw3voLA?^L7qV;A{P zwRinH)MOadT#Ombp6$bA&_+z_tB^NFvsV9oTxtjZTNNgszn(dIZU6fiUTZCmjQTrW z_SA5-$^7h|tLy}yzn->QgALk9dg}x8lnEpHj$uVj&bCpvhYUMj@5iyI!-*>govQuf z#xT{C<3<~CdkibYg=4~478Uk$pGmiXfsIt)CyWY~&VOY?W+4ZV&&IgHB8Mu4V-_)MJ;L(z8 zja{!*Su_;M8Q2H(SG&yt8zMq}Kh~|vK!5q$QX?)cXYPU2s+{~_u@TP0_4mb{zdIh> z>fxb>RTlai<#Tfu2b`N5>r|C#%-wvTOTE zRd%Tn;usncPH)K9#hfU+RJ^*LNB7^VEcAEi_unmSIPIN5yeemDglLe3jdOE^h{(d# zCtQrpLS+M^mH%fdteAT?Z*!l6j<~qA4GgDC!MJF#90kIs!=ffCb2ldX_VJy0z`!B` z!|1b!GzU4}$m!T8d$RBBJsp%QS1@kTLRUnK8z}4Z7qxy%!fJG$qhB7ZS87em=zECdVF ze;HuX$9K%e_OaAhy0Hnz6{(Zzcgul-*E)?_-xN8hm-@ZEcmxmLe{IlF1m~d&_49&Z zeixCJ$3nvOcm2!iM&3#8_1Yn&p&@iLroZh!IPOp%o684ZAP2)o1Gv7sIFiSD8A^Ky zp9zQ|M1)OXMTT!<#YYn`HnK40qth9DvqJ5{XFv&jV7|XH9zgGen1+~M?_6Tm+LlAPjs4uC|2|ruTDY% zS?ry}78_;_5F;kDkY|kXs{_Qw$rxk(R{_@fC#-ld`*9Pvru!1OjuI7<(Uijj#X}fU zVD3{`i-NU-)QNA;%}CG8!_)DBg;U>iS$)riH5{xwm8|@cfUGxyG~8{r{(PsN;SB1TQc zBvzgJ=-&kCLAbC5a6tD^TNA~xspwn%rw6*NeZ0nJb0_Djc0EoM4W^-Q^&cx(({5pQ zf78E~IpCHL7e7oIUlBJARfdVN5I*{k9W32&KI6fNK`X25Qj^4Tcna5lA0g(%g}qCL zygRwdLjMH?U$13X*KPM}P?fVKN!*~a`fo0b>l0pkMsDx9RTkglu`qpp|6uX1ufP5A z?L}2NkCH@(=~xu9__7G?Y%Xq1hlmku24;jkSwugN^>TVTt1Hh+R`2#z6Mgz?B{b7C z_*wNlR?|x(#L^j99JimxBak`5xd6JIHqoU3GdE65EWpRKP;sH4vPP}Er(s~U2rR5@ zkI#q{^~Sz<+uptDje_SISa@KF@*#72ikJpNy^ZQl7-Ymp9Boq*?nTV2vLQW-Slz}8 z;msZ0@SihU4=!93pc}&UpMThYwe9)|o6gfmY2_1Qh*T|Aon%Y%ceGAFVWjD64h*AJ z^pqa#TG?q@hiZ)$3FsEBX)O%1lEuLp%+HW)7eArT4B2*3eI^TOGBHiPO2}CqKED>X zXuI+;FeDtG;-cy60y<`hp)*;hf;?M##zg%3N%tWqn%!u*Ip8L;X*(Bxgm%i{^R^*U!0bQ0>nSn)*)sIzQ80aJpU}!`iz>?oyG@QkPz4};GqgfGQhuNe$e+1F@6F6>P**0btYst2)oz1!ih~)Q~ Qv&f&#oVRV>z-~wV51`?uJOBUy diff --git a/frontend/package.json b/frontend/package.json index 4d6f19c..f30b4b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-navigation-menu": "^1.1.4", + "@tanstack/react-query": "^5.20.4", "@tanstack/react-router": "^1.16.0", "@tanstack/router-devtools": "^1.16.0", "@tanstack/router-vite-plugin": "^1.16.1", diff --git a/frontend/src/components/navigation-menu/RootNavigationMenu.tsx b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx index 7cd0388..a2887b0 100644 --- a/frontend/src/components/navigation-menu/RootNavigationMenu.tsx +++ b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx @@ -2,7 +2,6 @@ import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, - NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle, } from "@/components/ui/navigation-menu"; @@ -35,14 +34,12 @@ export const RootNavigationMenu = () => { {NAVIGATION_CONFIG.map(({ title, route, icon }) => ( - - - + + +

{icon} {title} - +
diff --git a/frontend/src/components/ui/navigation-menu.tsx b/frontend/src/components/ui/navigation-menu.tsx index 6cc28bc..43229f2 100644 --- a/frontend/src/components/ui/navigation-menu.tsx +++ b/frontend/src/components/ui/navigation-menu.tsx @@ -77,8 +77,6 @@ const NavigationMenuContent = React.forwardRef< )); NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; -const NavigationMenuLink = NavigationMenuPrimitive.Link; - const NavigationMenuViewport = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -104,6 +102,5 @@ export { NavigationMenuItem, NavigationMenuContent, NavigationMenuTrigger, - NavigationMenuLink, NavigationMenuViewport, }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 37e614f..b32ae56 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,8 +4,10 @@ import "./index.css"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./react-query"; -const router = createRouter({ routeTree }); +const router = createRouter({ routeTree, context: { queryClient } }); declare module "@tanstack/react-router" { interface Register { @@ -18,7 +20,9 @@ if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + , ); } diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx new file mode 100644 index 0000000..902656c --- /dev/null +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -0,0 +1,14 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { attributesQueryOptions } from "./api"; + +export const Attributes = () => { + const { data } = useSuspenseQuery(attributesQueryOptions); + + return ( +
+ {data.data.map((d) => ( +
{d.name}
+ ))} +
+ ); +}; diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts new file mode 100644 index 0000000..c3c35fe --- /dev/null +++ b/frontend/src/pages/attributes/api.ts @@ -0,0 +1,10 @@ +import { AttributeQuery } from "@/types/attributes"; +import { queryOptions } from "@tanstack/react-query"; + +export const attributesQueryOptions = queryOptions({ + queryKey: ["attributes"], + queryFn: async () => { + const res = await fetch("http://localhost:3000/attributes"); + return res.json(); + }, +}); diff --git a/frontend/src/pages/attributes/index.ts b/frontend/src/pages/attributes/index.ts new file mode 100644 index 0000000..96df41c --- /dev/null +++ b/frontend/src/pages/attributes/index.ts @@ -0,0 +1,2 @@ +export * from "./AttributesPage"; +export * from "./api"; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts new file mode 100644 index 0000000..f927f7e --- /dev/null +++ b/frontend/src/pages/index.ts @@ -0,0 +1 @@ +export * from "./attributes"; diff --git a/frontend/src/react-query/client.ts b/frontend/src/react-query/client.ts new file mode 100644 index 0000000..6c7b9de --- /dev/null +++ b/frontend/src/react-query/client.ts @@ -0,0 +1,3 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient(); diff --git a/frontend/src/react-query/index.ts b/frontend/src/react-query/index.ts new file mode 100644 index 0000000..5ec7692 --- /dev/null +++ b/frontend/src/react-query/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 9ef2d2d..050a4dc 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,18 +13,18 @@ import { createFileRoute } from '@tanstack/react-router' // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as AttributesImport } from './routes/attributes' // Create Virtual Routes -const AttributesLazyImport = createFileRoute('/attributes')() const IndexLazyImport = createFileRoute('/')() // Create/Update Routes -const AttributesLazyRoute = AttributesLazyImport.update({ +const AttributesRoute = AttributesImport.update({ path: '/attributes', getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/attributes.lazy').then((d) => d.Route)) +} as any) const IndexLazyRoute = IndexLazyImport.update({ path: '/', @@ -40,7 +40,7 @@ declare module '@tanstack/react-router' { parentRoute: typeof rootRoute } '/attributes': { - preLoaderRoute: typeof AttributesLazyImport + preLoaderRoute: typeof AttributesImport parentRoute: typeof rootRoute } } @@ -50,7 +50,7 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren([ IndexLazyRoute, - AttributesLazyRoute, + AttributesRoute, ]) /* prettier-ignore-end */ diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index caafc95..cf2293c 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,6 +1,7 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import React, { Suspense } from "react"; import { RootNavigationMenu } from "@/components/navigation-menu"; +import { queryClient } from "@/react-query"; const TanStackRouterDevtools = process.env.NODE_ENV === "production" @@ -11,7 +12,9 @@ const TanStackRouterDevtools = })), ); -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext<{ + queryClient: typeof queryClient; +}>()({ component: () => ( <> diff --git a/frontend/src/routes/attributes.lazy.tsx b/frontend/src/routes/attributes.lazy.tsx deleted file mode 100644 index 1d912de..0000000 --- a/frontend/src/routes/attributes.lazy.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; - -export const Route = createLazyFileRoute("/attributes")({ - component: Attributes, -}); - -function Attributes() { - return
Hello from attributes!
; -} diff --git a/frontend/src/routes/attributes.tsx b/frontend/src/routes/attributes.tsx new file mode 100644 index 0000000..2c9c92a --- /dev/null +++ b/frontend/src/routes/attributes.tsx @@ -0,0 +1,8 @@ +import { Attributes, attributesQueryOptions } from "@/pages"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/attributes")({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(attributesQueryOptions), + component: Attributes, +}); diff --git a/frontend/src/types/attributes.ts b/frontend/src/types/attributes.ts new file mode 100644 index 0000000..fcd7bcc --- /dev/null +++ b/frontend/src/types/attributes.ts @@ -0,0 +1,21 @@ +export type AttributeType = { + id: string; + name: string; + createdAt: string; // ISO8601 string + labelIds: string[]; + deleted: boolean; +}; + +export type MetaType = { + offset: number; + limit: number; + searchText: string; + sortBy: string; + sortDir: string; + hasNextPage: boolean; +}; + +export type AttributeQuery = { + data: AttributeType[]; + meta: MetaType; +}; From e98da6f636c574f9095b21723d298b07a8ab6670 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Tue, 13 Feb 2024 18:53:50 +0100 Subject: [PATCH 05/17] feat/ router error fallback component --- frontend/bun.lockb | Bin 126117 -> 126141 bytes frontend/package.json | 1 + .../src/components/RouterErrorFallback.tsx | 28 +++++++++ frontend/src/components/ui/button.tsx | 56 ++++++++++++++++++ frontend/src/routes/__root.tsx | 6 +- frontend/src/routes/attributes.tsx | 2 + 6 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/RouterErrorFallback.tsx create mode 100644 frontend/src/components/ui/button.tsx diff --git a/frontend/bun.lockb b/frontend/bun.lockb index c676e93304a548658de88584ec3986b74911e78e..f859d7e398e73cb5b59e811925ab526f7cd5e474 100755 GIT binary patch delta 11810 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 Bm4N^N diff --git a/frontend/package.json b/frontend/package.json index f30b4b6..1038261 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-query": "^5.20.4", "@tanstack/react-router": "^1.16.0", "@tanstack/router-devtools": "^1.16.0", diff --git a/frontend/src/components/RouterErrorFallback.tsx b/frontend/src/components/RouterErrorFallback.tsx new file mode 100644 index 0000000..1a4fd60 --- /dev/null +++ b/frontend/src/components/RouterErrorFallback.tsx @@ -0,0 +1,28 @@ +import { Button } from "@/components/ui/button"; +import { AlertTriangleIcon, RefreshCw } from "lucide-react"; + +export const RouterErrorFallaback = () => { + return ( +
+
+
+ +
+ Error +
+
+
+ Oops! Something went wrong. Please try refreshing the page. +
+ +
+
+ ); +}; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..0ba4277 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index cf2293c..62fdc41 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -16,14 +16,14 @@ export const Route = createRootRouteWithContext<{ queryClient: typeof queryClient; }>()({ component: () => ( - <> +
-
+
- +
), }); diff --git a/frontend/src/routes/attributes.tsx b/frontend/src/routes/attributes.tsx index 2c9c92a..203f3b4 100644 --- a/frontend/src/routes/attributes.tsx +++ b/frontend/src/routes/attributes.tsx @@ -1,3 +1,4 @@ +import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; import { Attributes, attributesQueryOptions } from "@/pages"; import { createFileRoute } from "@tanstack/react-router"; @@ -5,4 +6,5 @@ export const Route = createFileRoute("/attributes")({ loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(attributesQueryOptions), component: Attributes, + errorComponent: RouterErrorFallaback, }); From e499458d421112c2465d657cb46b05629f0de651 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Wed, 14 Feb 2024 15:14:12 +0100 Subject: [PATCH 06/17] feat/ attributes page search integrated to router loader --- frontend/bun.lockb | Bin 126141 -> 126173 bytes frontend/package.json | 3 +- frontend/src/components/ui/input.tsx | 25 ++++++++ frontend/src/lib/debounce-state.hook.ts | 26 +++++++++ .../src/pages/attributes/AttributesPage.tsx | 41 +++++++++++-- .../pages/attributes/AttributesPage.types.ts | 3 + frontend/src/pages/attributes/api.ts | 55 +++++++++++++++--- frontend/src/routes/attributes.tsx | 16 ++++- 8 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/lib/debounce-state.hook.ts create mode 100644 frontend/src/pages/attributes/AttributesPage.types.ts diff --git a/frontend/bun.lockb b/frontend/bun.lockb index f859d7e398e73cb5b59e811925ab526f7cd5e474..9ba2735e450e970725460c178b3d59270c67aa66 100755 GIT binary patch delta 12311 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 TMC-9yk0bsIPcJoV delta 12160 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�R-Z40Q*tz4DbiwRdqT?3=X`6?90F_z$3sh;0Ul4)wZ!Z@`r(YfdfD@;D`P; zU@pMtkUf9`8UY?rtpU~otI1(g2e}-m1$bt*7+3<-2rP>!FyLA9-9RI-8`uDB0@ebn z03N0<19(cm2v`7=0tdjHY?Z^=7^HE}^vmg8>sk|FRTHqi`LqPe?aU6I6co4V` zI14=wIz9!y0C;A@gP|t?iE^Hj--kT^{x}H9H|2T2dgM0%T=wi-%Ju=PfieIu z&H=3mnPMOd@B%r&D1d(gT@PdeJY9Jb;PHDtz$3Xk2;|EFzIo39#sW71BLE)v@o)Pr zz&2nzu#P|(H&Y(4lc!ZYFDL+}0Y3x&1S18x5m*Y`2xJ31M`Zp6fIH6s#sl0|vw^9= zbYK=R1K>8A1WX2~BWTm}MmRz$=83GjI9eD^#_mvgH;RM+XOJ6UF~H5h*<^^EN!D`! zoY~s}jy0FpZ2(*3(69%Lge@^d_K-cz2G|UTh*69JMgkn-bRY!4E()02=^Bs@055A! zAQaGn_CPy;Cs^dy0+B#xpd%0gbh7hZAbB;32BLsofD7me!~opY-2m=%>Zyz6>7^T) z?zSR$3Fr$>2R(sApbyX+=n3=(`T@znKp+Vi09*&80jWR=FvQM#ATxlWz%YOdjSD6d zxE}4B!;}M=4S0dk04JTp_XE-xJI_VH)yCmt8CTUrfD0lx@!YuF9-J0Gz(v4?;R855 z)XxTji!eZ=TY))12|$OePz2DJ^En?V1lUk9Kppc7fihqbPz7-9Fkfj)@})oxPzx*v z>VcI2%gWi;)yUKV9EnwcU0_~j*0kZ&v%z(?j`{TfM<`f!DX(&F5;rUix}xbu+cao` zwoP{pe?cF#W8Jp-tv}(H%G6gkiAY!5CO5Y=Rq?qZVQAappL+UfOP4(_$`$VDVAUh= z%GqxM3RQD1Zf}L^7rEl$E}UzJ(6oz?-vRyA9pl8{=zL_~0p13F54;6TS0~4zVvPC> z7NV<)H$;PoQ-|@;l$ecjE;Tk133stT3=j=;1G3e~@uJGf#nD+kF2Pkk_6 zY}Fg>zkW*9;ylr>=5m{{bx^d@EmO8&UG_A96!T?*lZjO?Z zM4qTnrzeRtckr96RPQ}~SFG*Q-63YD4N4o7g1e9!JQ;Rd)XK@i}(*dVL#6uGeO}5$$-|hm? z2+-8_Uwt}YOjM{t7qKEzO{@~#oao%Ay6Swhh#eaI7W3S^ znbSWQ@xo|yYnavj;MbZDl&3yk`uW^_=D3-GnOpXP(lEpRO&x(i1kU^>3O2H35*FIACn*qZo z9=99u;6|ZdpMgF#D&Z4palsEi52n36VgKO^9@`G9z*_U;?cL=LegQi0_lGLqT=3W} zmkqb5H+`Z=v{%Dt!tXwnSSzB!%z1fCHOv&NGlJi6PM>q`hwrxe|JCdzLb?1k=g4nf zc=XlILyJ-3v4-Lsm&%$YV%@>-I|mHD>Cfk9MSX2sV8GMWqFK0@1V7c>IB(~p&z?H@ zq3IlR!e^nM&%$V~RjsfSN=4iP@v_RfMdUfYcB^f-zXZ!UR%-{*GI=$n0g%25Bt${p2L zehkTwv_T$irW!mO>;KzC^{d%f{~>DbY>^hEaltP^&m6h4)7x){Vc`XOS$ol{FhERH z-_90^qPE3#tN5L;T`S=i!>znaP4kNh!F);~u2&sf(EMk%txKJsE7kF9gbG|LMHMC_vIUoscp>Jih1D4FXIdJn#c`Cj0TW3M>2(WdtCWw;T(I z_d@ii_~M1MOsTaZ2e-Lue@W@mlCpVYXZ`lt&j&u*FIJshD-v+q(CbA)VpNEAcYJSp z?LGPT_jDzI;}N-TbLFDB|o_^yN0x23sWToCI0!y>%p&2^$ {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/lib/debounce-state.hook.ts b/frontend/src/lib/debounce-state.hook.ts new file mode 100644 index 0000000..8021667 --- /dev/null +++ b/frontend/src/lib/debounce-state.hook.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; + +type HookReturnType = { + debounced: [T, React.Dispatch>]; + original: T; +}; + +export const useDebouncedState = ( + initialState: T, + delay: number, +): HookReturnType => { + const [state, setState] = useState(initialState); + const [debouncedState, setDebouncedState] = useState(initialState); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedState(state); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [state, delay]); + + return { debounced: [debouncedState, setState], original: state }; +}; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index 902656c..e3b37ee 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -1,14 +1,45 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { Input } from "@/components/ui/input"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { attributesQueryOptions } from "./api"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/attributes"; +import { useEffect } from "react"; +import { useDebouncedState } from "@/lib/debounce-state.hook"; export const Attributes = () => { - const { data } = useSuspenseQuery(attributesQueryOptions); + const navigate = useNavigate({ from: Route.fullPath }); + const { searchText } = Route.useSearch(); + const { data } = useSuspenseInfiniteQuery( + attributesQueryOptions(Route.useLoaderDeps()), + ); + const { + debounced: [searchDraft, setSearchDraft], + original: originalSearchDraft, + } = useDebouncedState(searchText ?? "", 300); + + useEffect(() => { + navigate({ + search: (old) => { + return { + ...old, + searchText: searchDraft || undefined, + }; + }, + replace: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchDraft]); return (
- {data.data.map((d) => ( -
{d.name}
- ))} + setSearchDraft(e.target.value)} + /> + {data.pages.map((page) => + page.data.map((attr) =>
{attr.name}
), + )}
); }; diff --git a/frontend/src/pages/attributes/AttributesPage.types.ts b/frontend/src/pages/attributes/AttributesPage.types.ts new file mode 100644 index 0000000..4256624 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesPage.types.ts @@ -0,0 +1,3 @@ +export type AttributesQueryOptions = { + searchText?: string; +}; diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts index c3c35fe..dd67324 100644 --- a/frontend/src/pages/attributes/api.ts +++ b/frontend/src/pages/attributes/api.ts @@ -1,10 +1,47 @@ import { AttributeQuery } from "@/types/attributes"; -import { queryOptions } from "@tanstack/react-query"; - -export const attributesQueryOptions = queryOptions({ - queryKey: ["attributes"], - queryFn: async () => { - const res = await fetch("http://localhost:3000/attributes"); - return res.json(); - }, -}); +import { infiniteQueryOptions } from "@tanstack/react-query"; +import { AttributesQueryOptions } from "./AttributesPage.types"; + +export const QUERY_KEY = "attributes"; +const DEFAULT_LIMIT = 10; + +export const fetchAttributes = async ({ + pageParam, + opts, +}: { + pageParam: unknown; + opts: AttributesQueryOptions; +}) => { + const url = new URL("http://localhost:3000/attributes"); + const { searchText } = opts; + + const params = new URLSearchParams({ + offset: typeof pageParam === "number" ? pageParam.toString() : "0", + limit: DEFAULT_LIMIT.toString(), + }); + + if (searchText) { + params.set("searchText", encodeURIComponent(searchText)); + } + + url.search = params.toString(); + + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + +export const attributesQueryOptions = (opts: { searchText?: string }) => + infiniteQueryOptions({ + queryKey: [QUERY_KEY, opts], + queryFn: ({ pageParam }) => fetchAttributes({ pageParam, opts }), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.meta.offset + lastPage.meta.offset; + return lastPage.meta.hasNextPage ? nextOffset : undefined; + }, + }); diff --git a/frontend/src/routes/attributes.tsx b/frontend/src/routes/attributes.tsx index 203f3b4..434188d 100644 --- a/frontend/src/routes/attributes.tsx +++ b/frontend/src/routes/attributes.tsx @@ -1,10 +1,22 @@ import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; import { Attributes, attributesQueryOptions } from "@/pages"; import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; export const Route = createFileRoute("/attributes")({ - loader: ({ context: { queryClient } }) => - queryClient.ensureQueryData(attributesQueryOptions), + validateSearch: z.object({ + searchText: z.string().optional(), + }).parse, + loaderDeps: ({ search: { searchText } }) => ({ searchText }), + loader: async ({ context: { queryClient }, deps }) => { + const options = attributesQueryOptions(deps); + + const data = + queryClient.getQueryData(options.queryKey) ?? + (await queryClient.fetchInfiniteQuery(options)); + + return data; + }, component: Attributes, errorComponent: RouterErrorFallaback, }); From 1e6a1f049eaa1b912b68ef0c0269841af4c0ff33 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Wed, 14 Feb 2024 20:24:53 +0100 Subject: [PATCH 07/17] feat/ virtualized list with lazy loading and scroll restoration --- frontend/bun.lockb | Bin 126173 -> 127008 bytes frontend/package.json | 1 + .../src/pages/attributes/Attributes.hooks.ts | 49 ++++++++++++++++++ .../src/pages/attributes/AttributesPage.tsx | 47 ++++++++++++++--- frontend/src/pages/attributes/api.ts | 5 +- frontend/src/routes/__root.tsx | 14 ++++- frontend/src/routes/attributes.tsx | 4 +- 7 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 frontend/src/pages/attributes/Attributes.hooks.ts diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 9ba2735e450e970725460c178b3d59270c67aa66..e822c69383a2d96c96f6df2e483f581ae0944473 100755 GIT binary patch delta 20185 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 Y>g??CD(l2{u9(BRG~YRYE92Jx10bi?0{{R3 diff --git a/frontend/package.json b/frontend/package.json index ef119df..d49fa21 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-query": "^5.20.4", "@tanstack/react-router": "^1.16.0", + "@tanstack/react-virtual": "^3.0.4", "@tanstack/router-devtools": "^1.16.0", "@tanstack/router-vite-plugin": "^1.16.1", "class-variance-authority": "^0.7.0", diff --git a/frontend/src/pages/attributes/Attributes.hooks.ts b/frontend/src/pages/attributes/Attributes.hooks.ts new file mode 100644 index 0000000..0167b22 --- /dev/null +++ b/frontend/src/pages/attributes/Attributes.hooks.ts @@ -0,0 +1,49 @@ +import { AttributeType } from "@/types/attributes"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef, useEffect } from "react"; + +type HookParams = { + hasNextPage: boolean; + allRows: AttributeType[]; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}; + +export const useVirtualScroll = ({ + hasNextPage, + allRows, + isFetchingNextPage, + fetchNextPage, +}: HookParams) => { + const scrollerRef = useRef(null); + const virtualizer = useVirtualizer({ + count: hasNextPage ? allRows.length + 1 : allRows.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 100, + }); + const vritualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const [lastItem] = [...vritualItems].reverse(); + + if (!lastItem) { + return; + } + + if ( + lastItem.index >= allRows.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [ + hasNextPage, + fetchNextPage, + allRows.length, + isFetchingNextPage, + vritualItems, + ]); + + return { virtualizer, scrollerRef }; +}; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index e3b37ee..6b876fd 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -5,13 +5,23 @@ import { useNavigate } from "@tanstack/react-router"; import { Route } from "@/routes/attributes"; import { useEffect } from "react"; import { useDebouncedState } from "@/lib/debounce-state.hook"; +import { useVirtualScroll } from "./Attributes.hooks"; export const Attributes = () => { const navigate = useNavigate({ from: Route.fullPath }); const { searchText } = Route.useSearch(); - const { data } = useSuspenseInfiniteQuery( - attributesQueryOptions(Route.useLoaderDeps()), - ); + const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = + useSuspenseInfiniteQuery(attributesQueryOptions(Route.useLoaderDeps())); + + const allRows = data.pages.flatMap((d) => d.data); + + const { virtualizer, scrollerRef } = useVirtualScroll({ + hasNextPage, + allRows, + isFetchingNextPage, + fetchNextPage, + }); + const { debounced: [searchDraft, setSearchDraft], original: originalSearchDraft, @@ -37,9 +47,34 @@ export const Attributes = () => { value={originalSearchDraft} onChange={(e) => setSearchDraft(e.target.value)} /> - {data.pages.map((page) => - page.data.map((attr) =>
{attr.name}
), - )} +
+
+ {virtualizer.getVirtualItems().map((row) => { + const isLoaderRow = row.index > allRows.length - 1; + const attribute = allRows[row.index]; + + return ( +
+ {isLoaderRow + ? hasNextPage + ? "Loading more..." + : "Nothing more to load" + : attribute.name + " " + row.index} +
+ ); + })} +
+
); }; diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts index dd67324..afdb2a1 100644 --- a/frontend/src/pages/attributes/api.ts +++ b/frontend/src/pages/attributes/api.ts @@ -35,13 +35,14 @@ export const fetchAttributes = async ({ return response.json(); }; -export const attributesQueryOptions = (opts: { searchText?: string }) => +export const attributesQueryOptions = (opts: AttributesQueryOptions) => infiniteQueryOptions({ queryKey: [QUERY_KEY, opts], queryFn: ({ pageParam }) => fetchAttributes({ pageParam, opts }), initialPageParam: 0, getNextPageParam: (lastPage) => { - const nextOffset = lastPage.meta.offset + lastPage.meta.offset; + const nextOffset = lastPage.meta.offset + lastPage.meta.limit; + return lastPage.meta.hasNextPage ? nextOffset : undefined; }, }); diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 62fdc41..ecd4af8 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,4 +1,8 @@ -import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; +import { + createRootRouteWithContext, + Outlet, + ScrollRestoration, +} from "@tanstack/react-router"; import React, { Suspense } from "react"; import { RootNavigationMenu } from "@/components/navigation-menu"; import { queryClient } from "@/react-query"; @@ -19,6 +23,14 @@ export const Route = createRootRouteWithContext<{
+ { + const paths = ["/attributes"]; + return paths.includes(location.pathname) + ? location.pathname + : location.hash; + }} + />
diff --git a/frontend/src/routes/attributes.tsx b/frontend/src/routes/attributes.tsx index 434188d..a16b454 100644 --- a/frontend/src/routes/attributes.tsx +++ b/frontend/src/routes/attributes.tsx @@ -7,7 +7,9 @@ export const Route = createFileRoute("/attributes")({ validateSearch: z.object({ searchText: z.string().optional(), }).parse, - loaderDeps: ({ search: { searchText } }) => ({ searchText }), + loaderDeps: ({ search: { searchText } }) => ({ + searchText, + }), loader: async ({ context: { queryClient }, deps }) => { const options = attributesQueryOptions(deps); From 5987b11372724860bc84a83a9053bec585ec9477 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Fri, 16 Feb 2024 14:53:01 +0100 Subject: [PATCH 08/17] feat/ created a virtual list to separate it from the Page component --- frontend/bun.lockb | Bin 127008 -> 128189 bytes frontend/package.json | 1 + frontend/src/components/ui/scroll-area.tsx | 46 +++++++++++ .../attributes/AttributesList/ListItem.tsx | 19 +++++ .../VirtualizedList.hooks.ts} | 12 +-- .../AttributesList/VirtualizedList.tsx | 73 ++++++++++++++++++ .../pages/attributes/AttributesList/index.ts | 1 + .../src/pages/attributes/AttributesPage.tsx | 45 ++--------- 8 files changed, 153 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/pages/attributes/AttributesList/ListItem.tsx rename frontend/src/pages/attributes/{Attributes.hooks.ts => AttributesList/VirtualizedList.hooks.ts} (80%) create mode 100644 frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx create mode 100644 frontend/src/pages/attributes/AttributesList/index.ts diff --git a/frontend/bun.lockb b/frontend/bun.lockb index e822c69383a2d96c96f6df2e483f581ae0944473..85bd111ed66258fcdfd6d04daac0394fc2d4b8f6 100755 GIT binary patch delta 21023 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 T#^w;~NOe};`u*puH2l8+{BgUb diff --git a/frontend/package.json b/frontend/package.json index d49fa21..a589b28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-query": "^5.20.4", "@tanstack/react-router": "^1.16.0", diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cf253cf --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/pages/attributes/AttributesList/ListItem.tsx b/frontend/src/pages/attributes/AttributesList/ListItem.tsx new file mode 100644 index 0000000..66973cf --- /dev/null +++ b/frontend/src/pages/attributes/AttributesList/ListItem.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils"; +import { AttributeType } from "@/types/attributes"; + +type Props = { + attribute: AttributeType; + className?: string; +}; + +export const ListItem = ({ attribute, className }: Props) => { + return ( +
+

{attribute.name}

+

{attribute.labelIds.join(", ")}

+

+ {new Date(attribute.createdAt).toLocaleDateString()} +

+
+ ); +}; diff --git a/frontend/src/pages/attributes/Attributes.hooks.ts b/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts similarity index 80% rename from frontend/src/pages/attributes/Attributes.hooks.ts rename to frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts index 0167b22..07aa679 100644 --- a/frontend/src/pages/attributes/Attributes.hooks.ts +++ b/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts @@ -4,34 +4,34 @@ import { useRef, useEffect } from "react"; type HookParams = { hasNextPage: boolean; - allRows: AttributeType[]; + allItems: AttributeType[]; isFetchingNextPage: boolean; fetchNextPage: () => void; }; export const useVirtualScroll = ({ hasNextPage, - allRows, + allItems, isFetchingNextPage, fetchNextPage, }: HookParams) => { const scrollerRef = useRef(null); const virtualizer = useVirtualizer({ - count: hasNextPage ? allRows.length + 1 : allRows.length, + count: hasNextPage ? allItems.length + 1 : allItems.length, getScrollElement: () => scrollerRef.current, estimateSize: () => 100, }); const vritualItems = virtualizer.getVirtualItems(); useEffect(() => { - const [lastItem] = [...vritualItems].reverse(); + const lastItem = vritualItems.at(-1); if (!lastItem) { return; } if ( - lastItem.index >= allRows.length - 1 && + lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage ) { @@ -40,7 +40,7 @@ export const useVirtualScroll = ({ }, [ hasNextPage, fetchNextPage, - allRows.length, + allItems.length, isFetchingNextPage, vritualItems, ]); diff --git a/frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx b/frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx new file mode 100644 index 0000000..c231198 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx @@ -0,0 +1,73 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ListItem } from "./ListItem"; +import { useVirtualScroll } from "./VirtualizedList.hooks"; +import { AttributeQuery } from "@/types/attributes"; +import { InfiniteData } from "@tanstack/react-query"; + +type Props = { + isFetchingNextPage: boolean; + hasNextPage: boolean; + fetchNextPage: () => void; + data: InfiniteData; +}; + +export const VirtualizedList = ({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + data, +}: Props) => { + const allItems = data.pages.flatMap((d) => d.data); + + const { virtualizer, scrollerRef } = useVirtualScroll({ + hasNextPage, + allItems, + isFetchingNextPage, + fetchNextPage, + }); + + return ( +
+
+

Name

+

Labels

+

Created At

+
+ +
+ {virtualizer.getVirtualItems().map((row) => { + const isLoaderRow = row.index > allItems.length - 1; + const attribute = allItems[row.index]; + + return ( +
+ {isLoaderRow ? ( + hasNextPage ? ( + "Loading more..." + ) : ( + "Nothing more to load" + ) + ) : ( + + )} +
+ ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/pages/attributes/AttributesList/index.ts b/frontend/src/pages/attributes/AttributesList/index.ts new file mode 100644 index 0000000..31e3419 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesList/index.ts @@ -0,0 +1 @@ +export * from "./VirtualizedList"; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index 6b876fd..ec00c94 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -5,7 +5,7 @@ import { useNavigate } from "@tanstack/react-router"; import { Route } from "@/routes/attributes"; import { useEffect } from "react"; import { useDebouncedState } from "@/lib/debounce-state.hook"; -import { useVirtualScroll } from "./Attributes.hooks"; +import { VirtualizedList } from "./AttributesList"; export const Attributes = () => { const navigate = useNavigate({ from: Route.fullPath }); @@ -13,15 +13,6 @@ export const Attributes = () => { const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = useSuspenseInfiniteQuery(attributesQueryOptions(Route.useLoaderDeps())); - const allRows = data.pages.flatMap((d) => d.data); - - const { virtualizer, scrollerRef } = useVirtualScroll({ - hasNextPage, - allRows, - isFetchingNextPage, - fetchNextPage, - }); - const { debounced: [searchDraft, setSearchDraft], original: originalSearchDraft, @@ -47,34 +38,12 @@ export const Attributes = () => { value={originalSearchDraft} onChange={(e) => setSearchDraft(e.target.value)} /> -
-
- {virtualizer.getVirtualItems().map((row) => { - const isLoaderRow = row.index > allRows.length - 1; - const attribute = allRows[row.index]; - - return ( -
- {isLoaderRow - ? hasNextPage - ? "Loading more..." - : "Nothing more to load" - : attribute.name + " " + row.index} -
- ); - })} -
-
+
); }; From 8072faa6fe28c7c246d76bfb58b10aed86971df5 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Fri, 16 Feb 2024 17:17:58 +0100 Subject: [PATCH 09/17] feat/ react table with virtualized scroll --- frontend/bun.lockb | Bin 128189 -> 129006 bytes frontend/package.json | 1 + frontend/src/components/ui/table.tsx | 70 +++++++++ .../virtualized-table/VirtualTable.hook.ts} | 9 +- .../virtualized-table/VirtualizedTable.tsx | 146 ++++++++++++++++++ .../src/components/virtualized-table/index.ts | 1 + .../attributes/AttributesList/ListItem.tsx | 19 --- .../AttributesList/VirtualizedList.tsx | 73 --------- .../pages/attributes/AttributesList/index.ts | 1 - .../src/pages/attributes/AttributesPage.tsx | 6 +- .../AttributesTable/AttributesTable.tsx | 20 +++ .../AttributesTables.config.tsx | 18 +++ 12 files changed, 263 insertions(+), 101 deletions(-) create mode 100644 frontend/src/components/ui/table.tsx rename frontend/src/{pages/attributes/AttributesList/VirtualizedList.hooks.ts => components/virtualized-table/VirtualTable.hook.ts} (85%) create mode 100644 frontend/src/components/virtualized-table/VirtualizedTable.tsx create mode 100644 frontend/src/components/virtualized-table/index.ts delete mode 100644 frontend/src/pages/attributes/AttributesList/ListItem.tsx delete mode 100644 frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx delete mode 100644 frontend/src/pages/attributes/AttributesList/index.ts create mode 100644 frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx create mode 100644 frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 85bd111ed66258fcdfd6d04daac0394fc2d4b8f6..225418d111f2603574b567e83d6bd9769bacb726 100755 GIT binary patch delta 20766 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 GM*bH<1|T*7 delta 20084 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 KWu+{r$NvFu0w1dY diff --git a/frontend/package.json b/frontend/package.json index a589b28..4415795 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-query": "^5.20.4", "@tanstack/react-router": "^1.16.0", + "@tanstack/react-table": "^8.12.0", "@tanstack/react-virtual": "^3.0.4", "@tanstack/router-devtools": "^1.16.0", "@tanstack/router-vite-plugin": "^1.16.1", diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..670a664 --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( + = { hasNextPage: boolean; @@ -18,31 +18,33 @@ export const useVirtualScroll = ({ const virtualizer = useVirtualizer({ count: hasNextPage ? allItems.length + 1 : allItems.length, getScrollElement: () => scrollerRef.current, - estimateSize: () => 100, + estimateSize: () => 80, + measureElement: + typeof window !== "undefined" && + navigator.userAgent.indexOf("Firefox") === -1 + ? (element) => element?.getBoundingClientRect().height + : undefined, }); - const vritualItems = virtualizer.getVirtualItems(); - useEffect(() => { - const lastItem = vritualItems.at(-1); - - if (!lastItem) { - return; - } + const fetchMoreOnBottomReached = useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + if ( + scrollHeight - scrollTop - clientHeight < 80 && + !isFetchingNextPage && + hasNextPage + ) { + fetchNextPage(); + } + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage], + ); - if ( - lastItem.index >= allItems.length - 1 && - hasNextPage && - !isFetchingNextPage - ) { - fetchNextPage(); - } - }, [ - hasNextPage, - fetchNextPage, - allItems.length, - isFetchingNextPage, - vritualItems, - ]); + useEffect(() => { + fetchMoreOnBottomReached(scrollerRef.current); + }, [fetchMoreOnBottomReached]); - return { virtualizer, scrollerRef }; + return { virtualizer, scrollerRef, fetchMoreOnBottomReached }; }; diff --git a/frontend/src/components/virtualized-table/VirtualTable.types.ts b/frontend/src/components/virtualized-table/VirtualTable.types.ts new file mode 100644 index 0000000..05f1584 --- /dev/null +++ b/frontend/src/components/virtualized-table/VirtualTable.types.ts @@ -0,0 +1,14 @@ +import { ColumnDef } from "@tanstack/react-table"; + +export type TableSortState = { id: keyof T; desc: boolean }; + +export type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + hasNextPage: boolean; + className?: string; + fetchNextPage: () => void; + isFetchingNextPage: boolean; + onSort?: (sort?: TableSortState) => void; + initialSort?: TableSortState; +}; diff --git a/frontend/src/components/virtualized-table/VirtualizedTable.tsx b/frontend/src/components/virtualized-table/VirtualizedTable.tsx index ae12a3b..9c9ca52 100644 --- a/frontend/src/components/virtualized-table/VirtualizedTable.tsx +++ b/frontend/src/components/virtualized-table/VirtualizedTable.tsx @@ -1,8 +1,11 @@ import { - ColumnDef, + ColumnSort, + OnChangeFn, Row, + SortingState, flexRender, getCoreRowModel, + getSortedRowModel, useReactTable, } from "@tanstack/react-table"; @@ -16,16 +19,9 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { useVirtualScroll } from "./VirtualTable.hook"; -import { ArrowDownNarrowWideIcon, ArrowDownWideNarrowIcon } from "lucide-react"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - hasNextPage: boolean; - className?: string; - fetchNextPage: () => void; - isFetchingNextPage: boolean; -} +import { ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon } from "lucide-react"; +import { useState } from "react"; +import { DataTableProps, TableSortState } from "./VirtualTable.types"; export function VirtualizedDataTable({ columns, @@ -34,113 +30,142 @@ export function VirtualizedDataTable({ className, fetchNextPage, isFetchingNextPage, + onSort, + initialSort, }: DataTableProps) { + const [sorting, setSorting] = useState( + initialSort?.id ? [initialSort as ColumnSort] : [], + ); const table = useReactTable({ data, columns, + state: { + sorting, + }, getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualSorting: true, }); + const handleSortingChange: OnChangeFn = (updater) => { + if (table.getRowModel().rows.length) { + virtualizer.scrollToIndex?.(0); + } + + if (typeof updater === "function") { + const update = updater(table.getState().sorting); + setSorting(update); + if (onSort) { + const data = update[0] as ColumnSort; + onSort(data as TableSortState | undefined); + } + } + }; + + table.setOptions((prev) => ({ + ...prev, + onSortingChange: handleSortingChange, + })); + const { rows } = table.getRowModel(); - const { virtualizer, scrollerRef } = useVirtualScroll>({ - allItems: rows, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - }); + const { virtualizer, scrollerRef, fetchMoreOnBottomReached } = + useVirtualScroll>({ + allItems: rows, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }); return (
    fetchMoreOnBottomReached(e.target as HTMLDivElement)} ref={scrollerRef} - className={cn("overflow-auto border rounded-md", className)} > -
    -
    +)); + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }; diff --git a/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts b/frontend/src/components/virtualized-table/VirtualTable.hook.ts similarity index 85% rename from frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts rename to frontend/src/components/virtualized-table/VirtualTable.hook.ts index 07aa679..5038333 100644 --- a/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts +++ b/frontend/src/components/virtualized-table/VirtualTable.hook.ts @@ -1,20 +1,19 @@ -import { AttributeType } from "@/types/attributes"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef, useEffect } from "react"; -type HookParams = { +type HookParams = { hasNextPage: boolean; - allItems: AttributeType[]; + allItems: T[]; isFetchingNextPage: boolean; fetchNextPage: () => void; }; -export const useVirtualScroll = ({ +export const useVirtualScroll = ({ hasNextPage, allItems, isFetchingNextPage, fetchNextPage, -}: HookParams) => { +}: HookParams) => { const scrollerRef = useRef(null); const virtualizer = useVirtualizer({ count: hasNextPage ? allItems.length + 1 : allItems.length, diff --git a/frontend/src/components/virtualized-table/VirtualizedTable.tsx b/frontend/src/components/virtualized-table/VirtualizedTable.tsx new file mode 100644 index 0000000..ae12a3b --- /dev/null +++ b/frontend/src/components/virtualized-table/VirtualizedTable.tsx @@ -0,0 +1,146 @@ +import { + ColumnDef, + Row, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { useVirtualScroll } from "./VirtualTable.hook"; +import { ArrowDownNarrowWideIcon, ArrowDownWideNarrowIcon } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + hasNextPage: boolean; + className?: string; + fetchNextPage: () => void; + isFetchingNextPage: boolean; +} + +export function VirtualizedDataTable({ + columns, + data, + hasNextPage, + className, + fetchNextPage, + isFetchingNextPage, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const { rows } = table.getRowModel(); + + const { virtualizer, scrollerRef } = useVirtualScroll>({ + allItems: rows, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }); + + return ( +
    +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : ( +
    + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
    + )} +
    + ); + })} +
    + ))} +
    + + {virtualizer.getVirtualItems().map((virtualRow, index) => { + const isLoaderRow = virtualRow.index > rows.length - 1; + const row = rows[virtualRow.index] as Row; + + if (isLoaderRow) { + return ( + + + {hasNextPage ? "Loading more..." : "No more data!"} + + + ); + } + + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
    +
    +
    + ); +} diff --git a/frontend/src/components/virtualized-table/index.ts b/frontend/src/components/virtualized-table/index.ts new file mode 100644 index 0000000..b6c069c --- /dev/null +++ b/frontend/src/components/virtualized-table/index.ts @@ -0,0 +1 @@ +export * from "./VirtualizedTable"; diff --git a/frontend/src/pages/attributes/AttributesList/ListItem.tsx b/frontend/src/pages/attributes/AttributesList/ListItem.tsx deleted file mode 100644 index 66973cf..0000000 --- a/frontend/src/pages/attributes/AttributesList/ListItem.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { cn } from "@/lib/utils"; -import { AttributeType } from "@/types/attributes"; - -type Props = { - attribute: AttributeType; - className?: string; -}; - -export const ListItem = ({ attribute, className }: Props) => { - return ( -
    -

    {attribute.name}

    -

    {attribute.labelIds.join(", ")}

    -

    - {new Date(attribute.createdAt).toLocaleDateString()} -

    -
    - ); -}; diff --git a/frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx b/frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx deleted file mode 100644 index c231198..0000000 --- a/frontend/src/pages/attributes/AttributesList/VirtualizedList.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { ListItem } from "./ListItem"; -import { useVirtualScroll } from "./VirtualizedList.hooks"; -import { AttributeQuery } from "@/types/attributes"; -import { InfiniteData } from "@tanstack/react-query"; - -type Props = { - isFetchingNextPage: boolean; - hasNextPage: boolean; - fetchNextPage: () => void; - data: InfiniteData; -}; - -export const VirtualizedList = ({ - hasNextPage, - isFetchingNextPage, - fetchNextPage, - data, -}: Props) => { - const allItems = data.pages.flatMap((d) => d.data); - - const { virtualizer, scrollerRef } = useVirtualScroll({ - hasNextPage, - allItems, - isFetchingNextPage, - fetchNextPage, - }); - - return ( -
    -
    -

    Name

    -

    Labels

    -

    Created At

    -
    - -
    - {virtualizer.getVirtualItems().map((row) => { - const isLoaderRow = row.index > allItems.length - 1; - const attribute = allItems[row.index]; - - return ( -
    - {isLoaderRow ? ( - hasNextPage ? ( - "Loading more..." - ) : ( - "Nothing more to load" - ) - ) : ( - - )} -
    - ); - })} -
    -
    -
    - ); -}; diff --git a/frontend/src/pages/attributes/AttributesList/index.ts b/frontend/src/pages/attributes/AttributesList/index.ts deleted file mode 100644 index 31e3419..0000000 --- a/frontend/src/pages/attributes/AttributesList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./VirtualizedList"; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index ec00c94..ee588f8 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -5,7 +5,7 @@ import { useNavigate } from "@tanstack/react-router"; import { Route } from "@/routes/attributes"; import { useEffect } from "react"; import { useDebouncedState } from "@/lib/debounce-state.hook"; -import { VirtualizedList } from "./AttributesList"; +import { AttributesTable } from "./AttributesTable/AttributesTable"; export const Attributes = () => { const navigate = useNavigate({ from: Route.fullPath }); @@ -38,8 +38,8 @@ export const Attributes = () => { value={originalSearchDraft} onChange={(e) => setSearchDraft(e.target.value)} /> - d.data)} fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} hasNextPage={hasNextPage} diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx new file mode 100644 index 0000000..3d2f35f --- /dev/null +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx @@ -0,0 +1,20 @@ +import { VirtualizedDataTable } from "@/components/virtualized-table"; +import { AttributeType } from "@/types/attributes"; +import { COLUMNS } from "./AttributesTables.config"; + +type Props = { + isFetchingNextPage: boolean; + hasNextPage: boolean; + fetchNextPage: () => void; + data: AttributeType[]; +}; + +export const AttributesTable = (props: Props) => { + return ( + + ); +}; diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx new file mode 100644 index 0000000..11c676d --- /dev/null +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx @@ -0,0 +1,18 @@ +import { AttributeType } from "@/types/attributes"; +import { ColumnDef } from "@tanstack/react-table"; + +export const COLUMNS: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + header: "Labels", + cell: ({ row }) => row.original.labelIds.join(", "), + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(), + }, +]; From 785698dc2a8b87d58fcabb9a5d8ba8b94a09b141 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Fri, 16 Feb 2024 20:16:18 +0100 Subject: [PATCH 10/17] feat/sorting --- frontend/src/components/ui/table.tsx | 2 +- .../virtualized-table/VirtualTable.hook.ts | 50 ++-- .../virtualized-table/VirtualTable.types.ts | 14 ++ .../virtualized-table/VirtualizedTable.tsx | 221 ++++++++++-------- .../AttributesList/VirtualizedList.hooks.ts | 49 ++++ .../src/pages/attributes/AttributesPage.tsx | 30 ++- .../pages/attributes/AttributesPage.types.ts | 2 + .../AttributesTable/AttributesTable.tsx | 11 +- frontend/src/pages/attributes/api.ts | 10 +- frontend/src/routes/attributes.tsx | 6 +- 10 files changed, 259 insertions(+), 136 deletions(-) create mode 100644 frontend/src/components/virtualized-table/VirtualTable.types.ts create mode 100644 frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx index 670a664..12b6ff7 100644 --- a/frontend/src/components/ui/table.tsx +++ b/frontend/src/components/ui/table.tsx @@ -35,7 +35,7 @@ const TableRow = React.forwardRef<
    - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : ( -
    - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {{ - asc: , - desc: , - }[header.column.getIsSorted() as string] ?? null} -
    - )} -
    - ); - })} -
    - ))} -
    - - {virtualizer.getVirtualItems().map((virtualRow, index) => { - const isLoaderRow = virtualRow.index > rows.length - 1; - const row = rows[virtualRow.index] as Row; - - if (isLoaderRow) { +
    + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { return ( - - - {hasNextPage ? "Loading more..." : "No more data!"} - - +
    + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + desc: , + asc: , + }[header.column.getIsSorted() as string] ?? null} +
    + ); - } + })} +
    + ))} +
    + + {virtualizer.getVirtualItems().map((virtualRow) => { + const isLoaderRow = virtualRow.index > rows.length - 1; + const row = rows[virtualRow.index] as Row; + + if (!row) { + return null; + } - return ( - - {row.getVisibleCells().map((cell) => { - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ); - })} - - ); - })} - -
    -
+ return ( + virtualizer.measureElement(node)} //measure dynamic row height + key={row.id} + className="flex absolute w-full h-[80px]" // h-[80px] does not need to be there (I added it there cos I did not have enaugh data to make the infinite loading nicer expereinec since there is not enaugh data) + style={{ + transform: `translateY(${virtualRow.start}px)`, + }} + > + {isLoaderRow + ? "Is Loading" + : row.getVisibleCells().map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + + ); } diff --git a/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts b/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts new file mode 100644 index 0000000..07aa679 --- /dev/null +++ b/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts @@ -0,0 +1,49 @@ +import { AttributeType } from "@/types/attributes"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef, useEffect } from "react"; + +type HookParams = { + hasNextPage: boolean; + allItems: AttributeType[]; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}; + +export const useVirtualScroll = ({ + hasNextPage, + allItems, + isFetchingNextPage, + fetchNextPage, +}: HookParams) => { + const scrollerRef = useRef(null); + const virtualizer = useVirtualizer({ + count: hasNextPage ? allItems.length + 1 : allItems.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 100, + }); + const vritualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const lastItem = vritualItems.at(-1); + + if (!lastItem) { + return; + } + + if ( + lastItem.index >= allItems.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [ + hasNextPage, + fetchNextPage, + allItems.length, + isFetchingNextPage, + vritualItems, + ]); + + return { virtualizer, scrollerRef }; +}; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index ee588f8..5feb3e2 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -6,10 +6,12 @@ import { Route } from "@/routes/attributes"; import { useEffect } from "react"; import { useDebouncedState } from "@/lib/debounce-state.hook"; import { AttributesTable } from "./AttributesTable/AttributesTable"; +import { AttributesQueryOptions } from "./AttributesPage.types"; +import { AttributeType } from "@/types/attributes"; export const Attributes = () => { const navigate = useNavigate({ from: Route.fullPath }); - const { searchText } = Route.useSearch(); + const { searchText, sortBy, sortDir } = Route.useSearch(); const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = useSuspenseInfiniteQuery(attributesQueryOptions(Route.useLoaderDeps())); @@ -18,16 +20,32 @@ export const Attributes = () => { original: originalSearchDraft, } = useDebouncedState(searchText ?? "", 300); - useEffect(() => { + const setSearchParams = (params: AttributesQueryOptions) => { navigate({ search: (old) => { return { ...old, - searchText: searchDraft || undefined, + ...params, }; }, replace: true, }); + }; + + const handleSort = (sort?: { id: keyof AttributeType; desc: boolean }) => { + if (!sort) { + setSearchParams({ sortBy: undefined, sortDir: undefined }); + return; + } + + if (sort.id !== "name" && sort.id !== "createdAt") { + return; + } + setSearchParams({ sortBy: sort.id, sortDir: sort.desc ? "desc" : "asc" }); + }; + + useEffect(() => { + setSearchParams({ searchText: searchDraft || undefined }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchDraft]); @@ -39,6 +57,12 @@ export const Attributes = () => { onChange={(e) => setSearchDraft(e.target.value)} /> d.data)} fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} diff --git a/frontend/src/pages/attributes/AttributesPage.types.ts b/frontend/src/pages/attributes/AttributesPage.types.ts index 4256624..d9436b0 100644 --- a/frontend/src/pages/attributes/AttributesPage.types.ts +++ b/frontend/src/pages/attributes/AttributesPage.types.ts @@ -1,3 +1,5 @@ export type AttributesQueryOptions = { searchText?: string; + sortBy?: "name" | "createdAt"; + sortDir?: "asc" | "desc"; }; diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx index 3d2f35f..294479c 100644 --- a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx @@ -1,13 +1,12 @@ import { VirtualizedDataTable } from "@/components/virtualized-table"; import { AttributeType } from "@/types/attributes"; import { COLUMNS } from "./AttributesTables.config"; +import { ComponentProps } from "react"; -type Props = { - isFetchingNextPage: boolean; - hasNextPage: boolean; - fetchNextPage: () => void; - data: AttributeType[]; -}; +type Props = Omit< + ComponentProps>, + "columns" | "className" +>; export const AttributesTable = (props: Props) => { return ( diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts index afdb2a1..ee4da82 100644 --- a/frontend/src/pages/attributes/api.ts +++ b/frontend/src/pages/attributes/api.ts @@ -13,15 +13,19 @@ export const fetchAttributes = async ({ opts: AttributesQueryOptions; }) => { const url = new URL("http://localhost:3000/attributes"); - const { searchText } = opts; const params = new URLSearchParams({ offset: typeof pageParam === "number" ? pageParam.toString() : "0", limit: DEFAULT_LIMIT.toString(), }); - if (searchText) { - params.set("searchText", encodeURIComponent(searchText)); + for (const key in opts) { + if (Object.prototype.hasOwnProperty.call(opts, key)) { + const value = (opts as Record)[key]; + if (value) { + params.set(key, encodeURIComponent(value)); + } + } } url.search = params.toString(); diff --git a/frontend/src/routes/attributes.tsx b/frontend/src/routes/attributes.tsx index a16b454..85200b5 100644 --- a/frontend/src/routes/attributes.tsx +++ b/frontend/src/routes/attributes.tsx @@ -6,9 +6,13 @@ import { z } from "zod"; export const Route = createFileRoute("/attributes")({ validateSearch: z.object({ searchText: z.string().optional(), + sortBy: z.enum(["name", "createdAt"]).optional(), + sortDir: z.enum(["asc", "desc"]).optional(), }).parse, - loaderDeps: ({ search: { searchText } }) => ({ + loaderDeps: ({ search: { searchText, sortBy, sortDir } }) => ({ searchText, + sortBy, + sortDir, }), loader: async ({ context: { queryClient }, deps }) => { const options = attributesQueryOptions(deps); From 8f69eb7d733baa0c12028b6a9d7a0dc3d68b031a Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Fri, 16 Feb 2024 20:43:16 +0100 Subject: [PATCH 11/17] feat/ delete attribute from the list --- .../AttributesList/VirtualizedList.hooks.ts | 49 ------------------- .../AttributesTable/AttributesTable.tsx | 14 +++++- .../AttributesTables.config.tsx | 21 +++++++- frontend/src/pages/attributes/api.ts | 20 ++++++-- 4 files changed, 49 insertions(+), 55 deletions(-) delete mode 100644 frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts diff --git a/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts b/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts deleted file mode 100644 index 07aa679..0000000 --- a/frontend/src/pages/attributes/AttributesList/VirtualizedList.hooks.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { AttributeType } from "@/types/attributes"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useRef, useEffect } from "react"; - -type HookParams = { - hasNextPage: boolean; - allItems: AttributeType[]; - isFetchingNextPage: boolean; - fetchNextPage: () => void; -}; - -export const useVirtualScroll = ({ - hasNextPage, - allItems, - isFetchingNextPage, - fetchNextPage, -}: HookParams) => { - const scrollerRef = useRef(null); - const virtualizer = useVirtualizer({ - count: hasNextPage ? allItems.length + 1 : allItems.length, - getScrollElement: () => scrollerRef.current, - estimateSize: () => 100, - }); - const vritualItems = virtualizer.getVirtualItems(); - - useEffect(() => { - const lastItem = vritualItems.at(-1); - - if (!lastItem) { - return; - } - - if ( - lastItem.index >= allItems.length - 1 && - hasNextPage && - !isFetchingNextPage - ) { - fetchNextPage(); - } - }, [ - hasNextPage, - fetchNextPage, - allItems.length, - isFetchingNextPage, - vritualItems, - ]); - - return { virtualizer, scrollerRef }; -}; diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx index 294479c..ed05a3d 100644 --- a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx @@ -1,7 +1,10 @@ import { VirtualizedDataTable } from "@/components/virtualized-table"; import { AttributeType } from "@/types/attributes"; -import { COLUMNS } from "./AttributesTables.config"; +import { getColumns } from "./AttributesTables.config"; import { ComponentProps } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { ATTRIBUTES_QUERY_KEY, deleteAttribute } from ".."; +import { queryClient } from "@/react-query"; type Props = Omit< ComponentProps>, @@ -9,10 +12,17 @@ type Props = Omit< >; export const AttributesTable = (props: Props) => { + const mutation = useMutation({ + mutationFn: deleteAttribute, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ATTRIBUTES_QUERY_KEY] }); + }, + }); + return ( ); diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx index 11c676d..12ab325 100644 --- a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx @@ -1,7 +1,11 @@ +import { Button } from "@/components/ui/button"; import { AttributeType } from "@/types/attributes"; import { ColumnDef } from "@tanstack/react-table"; +import { TrashIcon } from "lucide-react"; -export const COLUMNS: ColumnDef[] = [ +export const getColumns = ( + onDelete: (id: string) => void, +): ColumnDef[] => [ { accessorKey: "name", header: "Name", @@ -15,4 +19,19 @@ export const COLUMNS: ColumnDef[] = [ header: "Created At", cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(), }, + { + accessorKey: "delete", + header: "", + cell: ({ row }) => ( +
+ +
+ ), + }, ]; diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts index ee4da82..aeb6627 100644 --- a/frontend/src/pages/attributes/api.ts +++ b/frontend/src/pages/attributes/api.ts @@ -1,8 +1,8 @@ import { AttributeQuery } from "@/types/attributes"; -import { infiniteQueryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query"; import { AttributesQueryOptions } from "./AttributesPage.types"; -export const QUERY_KEY = "attributes"; +export const ATTRIBUTES_QUERY_KEY = "attributes"; const DEFAULT_LIMIT = 10; export const fetchAttributes = async ({ @@ -41,7 +41,7 @@ export const fetchAttributes = async ({ export const attributesQueryOptions = (opts: AttributesQueryOptions) => infiniteQueryOptions({ - queryKey: [QUERY_KEY, opts], + queryKey: [ATTRIBUTES_QUERY_KEY, opts], queryFn: ({ pageParam }) => fetchAttributes({ pageParam, opts }), initialPageParam: 0, getNextPageParam: (lastPage) => { @@ -49,4 +49,18 @@ export const attributesQueryOptions = (opts: AttributesQueryOptions) => return lastPage.meta.hasNextPage ? nextOffset : undefined; }, + placeholderData: keepPreviousData, }); + +/*DLETE QUERY*/ +export const deleteAttribute = async (id: string) => { + const response = await fetch(`http://localhost:3000/attributes/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; From 16e1bb2d92aec2109c3903082852e31b33965579 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Sat, 17 Feb 2024 11:48:04 +0100 Subject: [PATCH 12/17] feat/ attribute detail page --- frontend/src/components/ui/card.tsx | 79 +++++++++++++++++++ .../virtualized-table/VirtualTable.types.ts | 3 +- .../virtualized-table/VirtualizedTable.tsx | 4 +- .../src/pages/attribute/AttributeCard.tsx | 40 ++++++++++ .../src/pages/attribute/AttributePage.tsx | 24 ++++++ frontend/src/pages/attribute/api.ts | 22 ++++++ frontend/src/pages/attribute/index.ts | 1 + .../src/pages/attributes/AttributesPage.tsx | 8 +- .../AttributesTables.config.tsx | 5 +- frontend/src/routeTree.gen.ts | 29 ++++--- frontend/src/routes/attributes.attribute.tsx | 16 ++++ .../{attributes.tsx => attributes.index.tsx} | 2 +- 12 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/pages/attribute/AttributeCard.tsx create mode 100644 frontend/src/pages/attribute/AttributePage.tsx create mode 100644 frontend/src/pages/attribute/api.ts create mode 100644 frontend/src/pages/attribute/index.ts create mode 100644 frontend/src/routes/attributes.attribute.tsx rename frontend/src/routes/{attributes.tsx => attributes.index.tsx} (93%) diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/components/virtualized-table/VirtualTable.types.ts b/frontend/src/components/virtualized-table/VirtualTable.types.ts index 05f1584..cfe98ac 100644 --- a/frontend/src/components/virtualized-table/VirtualTable.types.ts +++ b/frontend/src/components/virtualized-table/VirtualTable.types.ts @@ -1,4 +1,4 @@ -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, Row } from "@tanstack/react-table"; export type TableSortState = { id: keyof T; desc: boolean }; @@ -11,4 +11,5 @@ export type DataTableProps = { isFetchingNextPage: boolean; onSort?: (sort?: TableSortState) => void; initialSort?: TableSortState; + onRowClick?: (row: Row) => void; }; diff --git a/frontend/src/components/virtualized-table/VirtualizedTable.tsx b/frontend/src/components/virtualized-table/VirtualizedTable.tsx index 9c9ca52..2283314 100644 --- a/frontend/src/components/virtualized-table/VirtualizedTable.tsx +++ b/frontend/src/components/virtualized-table/VirtualizedTable.tsx @@ -32,6 +32,7 @@ export function VirtualizedDataTable({ isFetchingNextPage, onSort, initialSort, + onRowClick, }: DataTableProps) { const [sorting, setSorting] = useState( initialSort?.id ? [initialSort as ColumnSort] : [], @@ -135,10 +136,11 @@ export function VirtualizedDataTable({ return ( onRowClick?.(row)} data-index={virtualRow.index} //needed for dynamic row height measurement ref={(node) => virtualizer.measureElement(node)} //measure dynamic row height key={row.id} - className="flex absolute w-full h-[80px]" // h-[80px] does not need to be there (I added it there cos I did not have enaugh data to make the infinite loading nicer expereinec since there is not enaugh data) + className={`flex absolute w-full h-[80px] ${onRowClick ? "cursor-pointer" : ""}`} // h-[80px] does not need to be there (I added it there cos I did not have enaugh data to make the infinite loading nicer expereinec since there is not enaugh data) style={{ transform: `translateY(${virtualRow.start}px)`, }} diff --git a/frontend/src/pages/attribute/AttributeCard.tsx b/frontend/src/pages/attribute/AttributeCard.tsx new file mode 100644 index 0000000..d72e34b --- /dev/null +++ b/frontend/src/pages/attribute/AttributeCard.tsx @@ -0,0 +1,40 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "@/components/ui/card"; +import { AttributeType } from "@/types/attributes"; + +type Props = { + attribute: AttributeType; +}; + +export const AttributeCard = ({ attribute }: Props) => { + return ( + + + {attribute.name} + Attribute Detail + + +
+

Labels

+

{attribute.labelIds.join(", ")}

+
+
+

Created At

+

{new Date(attribute.createdAt).toLocaleDateString()}

+
+
+ + + +
+ ); +}; diff --git a/frontend/src/pages/attribute/AttributePage.tsx b/frontend/src/pages/attribute/AttributePage.tsx new file mode 100644 index 0000000..07af9f6 --- /dev/null +++ b/frontend/src/pages/attribute/AttributePage.tsx @@ -0,0 +1,24 @@ +import { Button } from "@/components/ui/button"; +import { Route } from "@/routes/attributes.attribute"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import { attributeQueryOptions } from "./api"; +import { AttributeCard } from "./AttributeCard"; +import { ArrowLeftIcon } from "lucide-react"; + +export const AttributePage = () => { + const { attributeId } = Route.useSearch(); + const { data } = useSuspenseQuery(attributeQueryOptions(attributeId)); + const router = useRouter(); + + return ( +
+
+ +
+ +
+ ); +}; diff --git a/frontend/src/pages/attribute/api.ts b/frontend/src/pages/attribute/api.ts new file mode 100644 index 0000000..092e59d --- /dev/null +++ b/frontend/src/pages/attribute/api.ts @@ -0,0 +1,22 @@ +import { AttributeType } from "@/types/attributes"; +import { queryOptions } from "@tanstack/react-query"; + +export const ATTRIBUTES_QUERY_KEY = "attribute"; + +export const fetchAttributeById = async (attributeId: string) => { + const url = new URL(`http://localhost:3000/attributes/${attributeId}`); + + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + +export const attributeQueryOptions = (attributeId: string) => + queryOptions<{ data: AttributeType }>({ + queryKey: [ATTRIBUTES_QUERY_KEY, attributeId], + queryFn: () => fetchAttributeById(attributeId), + }); diff --git a/frontend/src/pages/attribute/index.ts b/frontend/src/pages/attribute/index.ts new file mode 100644 index 0000000..be62c18 --- /dev/null +++ b/frontend/src/pages/attribute/index.ts @@ -0,0 +1 @@ +export * from "./AttributePage"; diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index 5feb3e2..a0f9009 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -2,7 +2,7 @@ import { Input } from "@/components/ui/input"; import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { attributesQueryOptions } from "./api"; import { useNavigate } from "@tanstack/react-router"; -import { Route } from "@/routes/attributes"; +import { Route } from "@/routes/attributes.index"; import { useEffect } from "react"; import { useDebouncedState } from "@/lib/debounce-state.hook"; import { AttributesTable } from "./AttributesTable/AttributesTable"; @@ -62,6 +62,12 @@ export const Attributes = () => { ? { id: sortBy, desc: sortDir === "desc" } : undefined } + onRowClick={(row) => { + navigate({ + to: "/attributes/attribute", + search: { attributeId: row.original.id }, + }); + }} onSort={handleSort} data={data.pages.flatMap((d) => d.data)} fetchNextPage={fetchNextPage} diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx index 12ab325..a5dd7c6 100644 --- a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx @@ -27,7 +27,10 @@ export const getColumns = ( diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 050a4dc..a5cea45 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,7 +13,8 @@ import { createFileRoute } from '@tanstack/react-router' // Import Routes import { Route as rootRoute } from './routes/__root' -import { Route as AttributesImport } from './routes/attributes' +import { Route as AttributesIndexImport } from './routes/attributes.index' +import { Route as AttributesAttributeImport } from './routes/attributes.attribute' // Create Virtual Routes @@ -21,16 +22,21 @@ const IndexLazyImport = createFileRoute('/')() // Create/Update Routes -const AttributesRoute = AttributesImport.update({ - path: '/attributes', - getParentRoute: () => rootRoute, -} as any) - const IndexLazyRoute = IndexLazyImport.update({ path: '/', getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) +const AttributesIndexRoute = AttributesIndexImport.update({ + path: '/attributes/', + getParentRoute: () => rootRoute, +} as any) + +const AttributesAttributeRoute = AttributesAttributeImport.update({ + path: '/attributes/attribute', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -39,8 +45,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexLazyImport parentRoute: typeof rootRoute } - '/attributes': { - preLoaderRoute: typeof AttributesImport + '/attributes/attribute': { + preLoaderRoute: typeof AttributesAttributeImport + parentRoute: typeof rootRoute + } + '/attributes/': { + preLoaderRoute: typeof AttributesIndexImport parentRoute: typeof rootRoute } } @@ -50,7 +60,8 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren([ IndexLazyRoute, - AttributesRoute, + AttributesAttributeRoute, + AttributesIndexRoute, ]) /* prettier-ignore-end */ diff --git a/frontend/src/routes/attributes.attribute.tsx b/frontend/src/routes/attributes.attribute.tsx new file mode 100644 index 0000000..fc1d848 --- /dev/null +++ b/frontend/src/routes/attributes.attribute.tsx @@ -0,0 +1,16 @@ +import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; +import { AttributePage } from "@/pages/attribute"; +import { attributeQueryOptions } from "@/pages/attribute/api"; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +export const Route = createFileRoute("/attributes/attribute")({ + validateSearch: z.object({ + attributeId: z.string(), + }).parse, + loaderDeps: ({ search: { attributeId } }) => ({ attributeId }), + loader: async ({ context: { queryClient }, deps }) => + queryClient.ensureQueryData(attributeQueryOptions(deps.attributeId)), + component: AttributePage, + errorComponent: RouterErrorFallaback, +}); diff --git a/frontend/src/routes/attributes.tsx b/frontend/src/routes/attributes.index.tsx similarity index 93% rename from frontend/src/routes/attributes.tsx rename to frontend/src/routes/attributes.index.tsx index 85200b5..c4206ac 100644 --- a/frontend/src/routes/attributes.tsx +++ b/frontend/src/routes/attributes.index.tsx @@ -3,7 +3,7 @@ import { Attributes, attributesQueryOptions } from "@/pages"; import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; -export const Route = createFileRoute("/attributes")({ +export const Route = createFileRoute("/attributes/")({ validateSearch: z.object({ searchText: z.string().optional(), sortBy: z.enum(["name", "createdAt"]).optional(), From c76f8717d529f83806aa40543b7e8348c3e54838 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Sat, 17 Feb 2024 12:08:53 +0100 Subject: [PATCH 13/17] feat/ sharing the api for attribute delete mutation --- frontend/bun.lockb | Bin 129006 -> 130427 bytes frontend/package.json | 1 + frontend/src/components/ui/toast.tsx | 127 ++++++++++++ frontend/src/components/ui/toaster.tsx | 33 +++ frontend/src/components/ui/use-toast.ts | 192 ++++++++++++++++++ .../src/pages/attribute/AttributeCard.tsx | 9 +- .../src/pages/attribute/AttributePage.tsx | 11 +- frontend/src/pages/attribute/api.ts | 4 +- .../AttributesTable/AttributesTable.tsx | 16 +- frontend/src/react-query/common-api.ts | 37 ++++ frontend/src/react-query/index.ts | 1 + frontend/src/routes/__root.tsx | 2 + 12 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/use-toast.ts create mode 100644 frontend/src/react-query/common-api.ts diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 225418d111f2603574b567e83d6bd9769bacb726..b2ef347036492603bc9077ce68c66301b672d875 100755 GIT binary patch delta 21568 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 Fe*xA)o&o>> delta 20766 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 HcAEbm1qJ}D diff --git a/frontend/package.json b/frontend/package.json index 4415795..0b902f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.20.4", "@tanstack/react-router": "^1.16.0", "@tanstack/react-table": "^8.12.0", diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 0000000..a822477 --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 0000000..a2209ba --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/frontend/src/components/ui/use-toast.ts b/frontend/src/components/ui/use-toast.ts new file mode 100644 index 0000000..1671307 --- /dev/null +++ b/frontend/src/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/frontend/src/pages/attribute/AttributeCard.tsx b/frontend/src/pages/attribute/AttributeCard.tsx index d72e34b..29d2c13 100644 --- a/frontend/src/pages/attribute/AttributeCard.tsx +++ b/frontend/src/pages/attribute/AttributeCard.tsx @@ -11,9 +11,10 @@ import { AttributeType } from "@/types/attributes"; type Props = { attribute: AttributeType; + onDelete: (attributeId: string) => void; }; -export const AttributeCard = ({ attribute }: Props) => { +export const AttributeCard = ({ attribute, onDelete }: Props) => { return ( @@ -31,7 +32,11 @@ export const AttributeCard = ({ attribute }: Props) => {
- diff --git a/frontend/src/pages/attribute/AttributePage.tsx b/frontend/src/pages/attribute/AttributePage.tsx index 07af9f6..3192ba3 100644 --- a/frontend/src/pages/attribute/AttributePage.tsx +++ b/frontend/src/pages/attribute/AttributePage.tsx @@ -5,12 +5,21 @@ import { useRouter } from "@tanstack/react-router"; import { attributeQueryOptions } from "./api"; import { AttributeCard } from "./AttributeCard"; import { ArrowLeftIcon } from "lucide-react"; +import { queryClient, useDeleteAttributeByIdQuery } from "@/react-query"; +import { ATTRIBUTES_QUERY_KEY } from "../attributes"; export const AttributePage = () => { const { attributeId } = Route.useSearch(); const { data } = useSuspenseQuery(attributeQueryOptions(attributeId)); const router = useRouter(); + const { mutate } = useDeleteAttributeByIdQuery(() => { + router.history.back(); + queryClient.invalidateQueries({ + queryKey: [ATTRIBUTES_QUERY_KEY], + }); + }); + return (
@@ -18,7 +27,7 @@ export const AttributePage = () => { Back to Attributes
- +
); }; diff --git a/frontend/src/pages/attribute/api.ts b/frontend/src/pages/attribute/api.ts index 092e59d..8fe3444 100644 --- a/frontend/src/pages/attribute/api.ts +++ b/frontend/src/pages/attribute/api.ts @@ -1,7 +1,7 @@ import { AttributeType } from "@/types/attributes"; import { queryOptions } from "@tanstack/react-query"; -export const ATTRIBUTES_QUERY_KEY = "attribute"; +export const ATTRIBUTE_QUERY_KEY = "attribute"; export const fetchAttributeById = async (attributeId: string) => { const url = new URL(`http://localhost:3000/attributes/${attributeId}`); @@ -17,6 +17,6 @@ export const fetchAttributeById = async (attributeId: string) => { export const attributeQueryOptions = (attributeId: string) => queryOptions<{ data: AttributeType }>({ - queryKey: [ATTRIBUTES_QUERY_KEY, attributeId], + queryKey: [ATTRIBUTE_QUERY_KEY, attributeId], queryFn: () => fetchAttributeById(attributeId), }); diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx index ed05a3d..ed6bf4f 100644 --- a/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTable.tsx @@ -2,9 +2,8 @@ import { VirtualizedDataTable } from "@/components/virtualized-table"; import { AttributeType } from "@/types/attributes"; import { getColumns } from "./AttributesTables.config"; import { ComponentProps } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { ATTRIBUTES_QUERY_KEY, deleteAttribute } from ".."; -import { queryClient } from "@/react-query"; +import { queryClient, useDeleteAttributeByIdQuery } from "@/react-query"; +import { ATTRIBUTES_QUERY_KEY } from ".."; type Props = Omit< ComponentProps>, @@ -12,17 +11,16 @@ type Props = Omit< >; export const AttributesTable = (props: Props) => { - const mutation = useMutation({ - mutationFn: deleteAttribute, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [ATTRIBUTES_QUERY_KEY] }); - }, + const { mutate } = useDeleteAttributeByIdQuery(() => { + queryClient.invalidateQueries({ + queryKey: [ATTRIBUTES_QUERY_KEY], + }); }); return ( ); diff --git a/frontend/src/react-query/common-api.ts b/frontend/src/react-query/common-api.ts new file mode 100644 index 0000000..eb90dab --- /dev/null +++ b/frontend/src/react-query/common-api.ts @@ -0,0 +1,37 @@ +import { useMutation } from "@tanstack/react-query"; +import { toast } from "@/components/ui/use-toast"; + +/* + * API + Query to delete attribute by id + */ +const deleteAttribute = async (id: string) => { + const response = await fetch(`http://localhost:3000/attributes/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); +}; + +export const useDeleteAttributeByIdQuery = (onSuccess?: () => void) => { + const mutation = useMutation({ + mutationFn: deleteAttribute, + onSuccess: () => { + onSuccess?.(); + toast({ + title: "Attribute has been successfully deleted", + variant: "destructive", + }); + }, + }); + + return { + mutate: mutation.mutate, + }; +}; +/* + * END of attribute delete query + */ diff --git a/frontend/src/react-query/index.ts b/frontend/src/react-query/index.ts index 5ec7692..77145de 100644 --- a/frontend/src/react-query/index.ts +++ b/frontend/src/react-query/index.ts @@ -1 +1,2 @@ export * from "./client"; +export * from "./common-api"; diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index ecd4af8..c2d17c1 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -6,6 +6,7 @@ import { import React, { Suspense } from "react"; import { RootNavigationMenu } from "@/components/navigation-menu"; import { queryClient } from "@/react-query"; +import { Toaster } from "@/components/ui/toaster"; const TanStackRouterDevtools = process.env.NODE_ENV === "production" @@ -36,6 +37,7 @@ export const Route = createRootRouteWithContext<{ +
), }); From eff15897f11dbc8ae9ffb5da98cae1f9f012a0b7 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Sat, 17 Feb 2024 12:37:28 +0100 Subject: [PATCH 14/17] feat/ async helmet for dynamic page titles --- frontend/bun.lockb | Bin 130427 -> 132447 bytes frontend/index.html | 2 +- frontend/package.json | 2 ++ frontend/src/main.tsx | 9 ++++++--- frontend/src/routes/attributes.attribute.tsx | 10 +++++++++- frontend/src/routes/attributes.index.tsx | 10 +++++++++- frontend/src/routes/index.lazy.tsx | 12 +++++++++--- 7 files changed, 36 insertions(+), 9 deletions(-) diff --git a/frontend/bun.lockb b/frontend/bun.lockb index b2ef347036492603bc9077ce68c66301b672d875..33b978be915646b7721d68558868701b356ea6ee 100755 GIT binary patch delta 23080 zcmeHvd0dp$_y2Q8Mi>+kTwqufL=6{^9b6dH6vPp?#1T|Kso<9NsnE=iR{BXR{l3q$0G0Kv?;pS4KRqwr&$;K^bC+}Ox%aux z%y55t**CqU-fIdeaidgn>S3F=^vv1x6`+UKWxr8?VywB z>b=0y)O%4+CNq+hS&)}oWXsK%ke4A#+ALV$QIg^yNev~bG~ZreEwU9#ITIA^7`-Ib zfxIZomTS)}mi$z{4X6v`Jy6aKbYynk2r5y>QPIz_=i2kFg@u{m4al#otxz8)Npb~0 z2DBdNbwyrM>I|*=5d4A+s`vqTDp-b_Gw5>Mh%N$O7c>mL@&J7XlscG-OrqY%r1GOc zN&kPVM=4(-m99W3>XBB_Y4nUbbPz(Sc$Z3_&$6dy!BTsnZM+S%C(I#>e4$JY=Ua=i zf=1hl358$ce>4k;1!gNVm4LR}od4)xgNlzN79fO?e%eCg_*)t^R zRq$w{wAh-HjrJv}8wAwjnX2MGbdMUE0G=w$@>D9wr$E3p06fKv4f*6Di>jBTmcI>i zslE{f)^voFl3Qkx2UkdX3fa37v%4O zuc&e%BcN37BT&kJfC)@FL8;=-pkzTCP{c&({8oy`ia{w7GcfamGII-y zQJ(m7t(AH*z?1%@Hp+OnCms<~>ep5gG*=bA3sNGt0F-)KA94ziyU@d^l%9dyA9MpK z`d&I4)DJWjlq~HAN*xaXCB6Bfk_6|J9z=PH)Vh#U$1Z>`AO*EHM!<7bmg3?%B zfE>eGniruoJQS2%JEzhpMjDA`#}9bSAZhC zN=rehL)IwxpBm^20nz5Ff?nlsMk*Da0;LY_2Xz6p6^$N?qSSU^eM4U`IosQFDm$pg+R|3fE5?*=I8 zod9hFx(k#VUZLi{qVkhKsr(6ekLKtaURK++#4|zR>Z$YyDEU9zHZGU^{Glpes?t@U zh)CW{T}&sp??ar1?Z^4AX=>?%*}jjL1Bq)A2KMUcYx9o zWFMKESAZej2%hE?J(jH5h0@!-6h70Qn-PSOZ zM?GZF4)A1lfjy_dnkh-j;|?qPs6M>Hv1Q4yK8ilp{c&WtLqCPj%(fO4*)pWLsD~CO zTjAIuJNjcQ95dEdP%K6ESL(GDViKd@6QD=K@h;@le`|VP0eLbPa&p9l=M;G=cp9dR zy!4_1doC|=GU`H;`BJBrB@P3XtXs*->hLU7DKc7sQVctTQb@KKq=cvho=m&jT`{#m ziqZu~P-=3&O1Fbj2=_@tS`}i3sbe=>F{qbHqd+OteL<-s5~we&i(jJv2~LAj z1-n4W18=GP98hXt3Mg4V4wO39S5nGV;^Ej7{6FSzAYbAUr(?Bwg@=hUJmc6KJjF9k*VvIS^)#_OUJlV(ZZyUj zK8H&>P>;E_e+*w@jAJFd9QT9V=oP2)GVoL{lVJ$#Atfh%!x*jebLJHg+AtEGAgs-o z8>0;yR8CJE`MjKkf?^W%w5xm@2)@u@HxPzQ-(37}7+79DOAj`)h(?e<7 zBie8Z9JTEr*Hjl1n>a^qMb8Vtg@MzNafa2^9C~>X9A+EkfoqM0r7Jkao@{V23I_{T zf$PApdURyhxY0k3jo~S{U*t>t;|#44DLpk!LlHQtlkpqA(d=WM(k#vpjwJ-KN;0o# z{St7U_#2HnvQN1&AWrXv*pDFZ7zQInJy6QN!OH{Ubh<{o!e}z|#ls*%X#`!J298`J z4-PxZ%bUmP>tgP8-bnK4>_5}Z6{23O1stSP0gvUdx>RaI`-1P+r39>h2su7T?-*WeFd2V>6DIH>*v zM_raVM8J~3IKzF&sC^x`qI5g2DicvU2OPEID97JUa8z23Kb?cO9HJ&Hn@Vv65Sz+V zTE`i-Lly-^2YK*+07sQzfYCsRkJ6#qwhK%fg| zSsKhRAMj_QPYF!nk)b(}n-b;cGv z6$Qq(&?1NIS_f_r3P}7$<7fkIMwO_dc^Lu@k6C!miqM_flBb57^goc04-N0AvpmBq z!cDq)&v35@lm4@3B+1NuBRc9v2lAy6Cc`_}TzA$=8@>hCP2u48&{oPSB5`=d@FqAK zA@l*>ybNxjtO@_=dbj3YQ6|Gntu?#IS2w|_6CaKUZ^J91O#1n4Bq@Pkjq0eo*oLP@ zoAeQFNia0JqhTUav}hrY+D01!F;+vAu3&(sfupG-8>v4JE?%C?UhTAQEXSnG2REGi zcI@PY#3)%EVnYWwE3JxK46b(-_Z2wmq#V445bWTGD|vy@hOOX6$Q(RtXozJ}@eS2E z2pkO|ljq+maQ&+^AAlQBRZAfvMCm$}I|$CCmD4lKts(rXPe;RSq!fRW)(vo7H7!GM zq%z)!T#snooJgJ;Ytr|LmZZUaXlzIQ+eoFzsalv9iYyH&j6ZU>AZ3+P?(HRMsGQ0} z$|k4IBQ;D;g(C!&)T>A-djF}C^+wPuZLCGgLM053k)pW(7hoQD>%_gfnDp^5t~>Yb z(osJfsRTK77O6xz<&Al-b49ya>8RCyQk(R){dYB9sA*86rBLGvkDUygd znnaDF4Sm2VQ)+o=v|+Z&!Jkc{bw_&e)I^iPEm2uqQNSl!p8zhNzY*HeFc&FWH1NFe zg>oWK?P)T2B9hhmj4`YyuYhbJWCc)E0=1!8TmP}F=zD^Tksqy7kb-5X&Wi9m432_S zZq(p_I7d9fWITrSG2l8-7jy-^cttOhZd)(zm1Hv9ft;Q!2vV;YrzE8Uc?~p-S2;Xb zuwK0f4lyY!MjP&cqheSJvCg%_#8et4E(aXBPT6Da1xLm~6J7iX9CZrzgKO7EaVwsN zc)&~mN9AOb^#>@AI;{T=sR()5Xo(3)eZYec3ssuRK_5f32pshRD*~3S^L=@0Ka;@~ zFGwV;MbllE)Q?v{I2A&Atf3EB5zD}lC3spx;;hhUMU} z?BJ>6-!j^84qRJsun&HGQk6$sT(UCMXaSy^3XW<~o>m_u^VETeBdkObiV#uS3!LIC zjK5(TICw~n;Pc>y$PLkeg;+2#!Qml}s!PD3c4ZiJ=Plf8kjcu7_AO?jZz=ERK#N6u2XE#P3V%6Vrhqb09lhAeOtM+h~H-Rt0Je3WJ2 z0=Ot}%1G87sd%Z5ybSaLN5QOw_GEArfAZWj>;*@)DCHi4qoGo^f6-aWLsaS6++T6Q z_8Ps`aXYWbFd0s%LPvfh08e;q8ED|-;<}7ce5uW(TRVzZ*i436qqTJk?$Na$%~LZ? zx{0ItQjmS4c?C%AZ0g%F2I%T6-xXR5>1P4b@21l3 zpmaTr>LJJ4F6jWQz48TGq9x>tu)4|=tEGGqr2;ThzKGTaFpVYZ0H&9$j~OCgL`ja= zldqbTI*O5(^%3OqHH_M)0wVxYNSDP5B{@c2zMe*@V;D2}B1-yjyIfDUER`gpRBw*T z|BjL?s9-)o6&3(=J&lsySULCClo}WZF#bZJhwRW|Rf{OqIuW4KlL5M(MjarZBIo{^ zQu%4BoG9td1gOjpHU~|uadPCO667qMAxWtqEzoXm0zdWuLwwS zJwWtrRe>n={9S;CWGg`V+f=$8lrEwq-$4wnnv~@40YrBKbP*-KWEXL`h>~EpO5X>i zp6&tYB1&Vq52y_s2IzVkCB0*Eu0pBa;{eeU02y)`z*QnANN`r>6-o`CS9zjtz?T3u z{EbTQfl@>F0V@BaN`C?+`4g4bp;cOy44_or71SBj2! zKx0&SM^OApop5Ua+DDc52c-rFg4O}Gg1Um{gVObZvUwjlMyp5HoTk}4-k$#RuesB)sDe?^sF zRpmr!Lfla0pR00E#oaXoP?MyZJazC(wLncuGxClqCrS<9Re7S6yr=RtDd~N$=Kny$ zO>4nVs^ZhAymla;Izrzf)Ack;S@fF@7f?4S#}Z+NS*yT{S6{PXdV2j*%{+r2oe| zM??-4_`h!*6_1TT9aK*`Ko?Q+WF|4Vo<^x-S?WS1Zy~9`Xn-op2IwM6d=4?Vo<^zM z|8?i+G8ToYfq%A+vK#){I{veD{AcU<)SVj^iT@XJWkr12-j$ZAf3}YQY#k8>w1xa< z>-hh7>lpce+B$A4pZI#qUdKH@2_2QS&2{MlkN>PmoBX}UN--s;f4{Hmdj!nhu;-UM z>k8iw@AJhkoz7nftN+^R><@RGDvYfRx#WFpu4IaK>^2u6z@*2nj}ZoAxP66%d#-fh zCs&wRAvdnH@T1^fSZQWsc^SCzt6WNsC+=uD-PrTFeRId=bh`h+nKc6^O?fgl;3p}? zZQ1zQ&Rh51-FIw-%ek`V`KFTbw)8B24j z-+Mwtdf)W}CoXb3Ie0nuUG3Rxjr*`JeRH#T`*&8ZZyE9CtA3AaFFMd*YW9=wD$>$6 z8)pyD&x?K|A$ zMZX^(txG*}_~d6{&-6W(_xN0a|E1fbzfHb3_NyG9NrOd~b3ACRC!f38Ous%_ZQ+$` zU3lyoGkcNGT4Ujh*SYX-!A<4u*IIaw^)CF4wPrS*-vsv~xTJMv_7Y#R&cfHd?ZSTn zH;X5(x9~w5T=+Zd&1^P*2+nDv3m^Kn8NZQQ|F(ti0_U>9%;xcw4HlmDjtl<~+yZXc zh~eMl!gDs78Rxsf9R=t6j+y<7+uyVY2o7tPZ99$*1*ezzZoX^@~VJr9z+*k7UTP{bGKa6ZgZs%SRS= zgfGGUD8Gk$8Bg46VaND#+>i5zxS!zt_F33TzJ4FZpe5If^kkY-ZPaIk-x2u}9482A_2VF;|9|1NQ}Qe-try3^8}q%)aC| z!Tks>smx5jaw|j39Y@T8yU7!eA?8jX=8l=!H~b+ur;~`e<7W0PUw<6-fpa-wX8++S zCt%+x*az+&H=Klhr(xeoGy9(J26q&k?^leh&Y0N~UJkAjTo34Ua4r|k%$28HgoWj>5S$w~T!Muau<(+ZHQ>9!9R=rGZf1?Ry&M)^hK1lfxUm8j zUV()bW@hAN;4Xm+x@>0Ny!bLKyb24!`SMm*VBs}bc*V?`@^WyM;9{?unLnR(6(e{Z z27(LV?XOu_bG`ug7W^jeEqRyg7WNEZf_osphkGlY_?d;Z=F4$!!yn?_miN11VL^O7 z?!laWZei_s3hp6%3+|!Z@C7XT5*B@7X5oA{xTD~F|7~WG-2QJ^^c5@u7tM`d!lJKX z(U)e{o|l2U1TN?+GwaBUzk)@Tun1f%Z}l}Sx(SQEHnTWh4z3bhY^52$=$KUri*CUp zaL@AgHxZNHV8m{kS$BRD+>hXrZkbsEUvdj0b{iuGE|DjGW5LfLmg8>b4{`6s``xy% zq;2bO4`cedc;?sN7W&<2#{KRb<$W7F7*g=5G(Pz*P3K8>FYDiT$H&#Y-`#nX$M@ZH z(#JPKJ|xe{+S-4nyu2;wyNOIc-W%##eOE|B7aPF8y1xn4_a)Xv`_G4jXS9~L`Q6E4 za`lrQIvE2Zl^@R1#~Iph{$GI1b=#Srn=-!Vk(0iCry7l9@Wnqz=$`Ms?bOe$nXa~Z zTkv;ba&6l}p5!xqD!vsqXdRLb6jyZ2Nq1#1?|ru`?|fJ9IYjx1aC){qcvPYC0gwDU zXMXek2K3ESu1(a{GbjCEeDLC;)i!A7mD;S#1k3I_UsC^7E3232ynr8GRmj2x+kVJs4L{&!LUud6+k>S+2 zx`6h%SZ`SmZ(c4C;){6s3lI7tgbbxGOZuy_erkpEMaTevF8ZQ`^y&l205vv1mC-jm zHh>zVFH-PFKf;!*0M%hpW%R|1gqvOz&pT1*g#vc0H8TQ zq0p^#Q($~LEr7X0wwwmXgjb2*a^G~YzDRgDad~v zcmsG7SOLrjIPfws9hd^pXA-pS9}g4&SaqZ#aib^msBJb*1<3tRh9syVpb34 z?QTUf9moJ|KqeCpda{-!>rtd3_`{$_0QydL8$h4#(T;gfw2I6cAQJb$e*`!R90QI6`v7`X>kH6oaSD7o zIWq&yU|=Uei{CQPSAoeuFCYQ<7|N9H3DA<%A9xO+CB_EONA`FU$UoW`25JQ?z#vsN z0+e(DX??;qXs|+^#jFHO5?WhmLeRvZ2@?Ud1bhLS06w6nK;r;f-%kSM%oV_!0EGyJ z3WeAdfP6zC^CCcI&;+N6J^?5OC~U?6+Ff{L>5Sg;vK5q^ASZfT=a)6bdg@^CfZt#Dp9xT45S8Ej>?7lMc-k z8mWZ<%@~TwB>=_ctAGG#>i!E@0H{yXCt6)p2kBMIVFLMtGRU9R8P#%XjO0`a>HSff z8vmm_s)IU3`E*rvh+O#V9#uC?)>1*rr^;zED8*}3pyp|nQU`zANzGeS&u@(^(X3yN zOszq3?OQ-iw?4J^(D`k{za1i~hlXhtP_sUDn7T_Vk?}`kRkL!f1LV3LKpe0J$OOcg z{>(#^_GfzIc2%?ubStn0*bKZY=Jsa+ZYPjD4jco@fTLo6e`YSB`R0u<_5x^OYyh|c z^?@PCa|Lw<9D!Eg^`H(wEr0=!A^QdR33voN1a1Rg0fT^l1785212=$yz-PcU;3`d| zb4XkP%7IJ38Nd_x1o#xV2wVWp1EhNvAUW|wD}V;TW#BsSB~S@`4cr870p9@M0zU!| zfIGl{fcrF&e?a1UfI4swxC?xzriuO+_!*$d{se?9rnC+qIe{#%4c-^j36y>rL!PJy z>H^dO>H_Wnd63#7uj=WMMeZXvk&7CGC)bcdBcwe655Nde!!%q3fAD@lQ=kdZ1u`>0 zu|e_{Ky#oO5CC*h%RGZLEv4b0VL(YJZXrNBAQ%V&+5)smX$`aj0s#uhE}&6BG!O}p zCDa*`QRj$i@=i$8Si1r3k&Xdq9q$0z5$Ft<=+_G=5R&DoKrDoDfL0L&B{dokJPT-* zYXL>^qE${05$X&Db9bN{P_x6NNBPuws*5@w!zXJZ#KPGF;gZ6_qeyOm-bBxikL)iE z$kd^RnBcJB5P4hXjvVdZ9jj~b)rlb~%#*bdV^dgn=MPW?LcFwbEpaG?_0qMhCEQb4 zn74L-Kxo{m7L%sBd7Z*mU3Ht6eq$DtPy976vk zVi7l>z-;0tBKhLonan7H2Q#lo?NEe}ywgh-c)Sov-HwFiA(D1{g7^B8>zlsbA*r>6 zV!0MC3`TR>0Sze|0&clXAGZezQD_bd+r*Z^EJ+vWuxJRZixb8n@KX4^yu#@2o;@JI^*ovgmpdn27yX8!T%{O2 z6y@&KR{p29j-z-BwKmZHrSvd3P?O4DHE>4;gm|S z^}uGN96Yeh^)0=Qor9ziBoD-gXv4dKv-}EJ8tQ!So7V9S9dzso6dFPyRosIDTO{fX zgPwL^h;OqS0s9QicB|gSpf4A3P>57c{E$j-XQU=y?X<5O6k_0hC}?MjY|IPYo_!~M znd~(=r%cSHy0ybaMvQ45+qsTw3=|NkG;*WFekib1ahdd<>IYd*u{KIO%_Nn*emVZX zZ|19=L`B*$Bq@iZ`rqpL@HTQnHL z5o~3i-twU$aLtX!CpQH~xGY932u9Y>=1;;;kJ*GCVQ8PP19bG+=6+$a)?z z5t4TN$jVXU#uh&w>=L zWJUOe-fymc7_BNIEaZSUip3*XggU#twc|?Ke7dsW%;>E{s+GG5md-}9eZrm&iwB4| z(qTxJ*qP2=jMUB=NgjUt$)ow!2XZfA3O4m#c$B~uvET38a;$!27ILB$SF9H~8SsI2 z3`y&DJ%0XiaKrCZ1;oQGu{;Ca*G?3fow0Dso{F;Ta@`mdyv>OJpa#}n7;NZKl87Zz zEXLR{U)PDfHq@>iPx6vut7S9rSKGEv7KF*p;2En;3Kgtx_2 z2)!r!$q$Iqj5+TOv|m|vM{OBhQx~S&qRmJYJ|y;Mvxbgg;c$NaW@7kA=Gjd5Z}utz(ZMEjp|;he>Mntus>Jh)4LyHeL@T25?$_!({L z5(C7-kt_@!)5*M#cJxW*x|dxqT&RsO|2RNd2ULy5+94^ri(^Vfm#{XndqPD0EY?^z zxP=JJVtzjKdhU+)tDXa_wfZEfXeoMTv86u0@0lq!G>Vkk1TL~O&nWHalVi-SqR)oP zM{+ZAcxZ>CJgDpT<<7gtl}ZPK!|}ik6us>%>`y8$ZY5qv?YjA`#Cy=x>2%_jowYWE zqJQKtZCL23aIUq88O2&R*YbYfWi4k?8!=`ow z^ln>mDi5@7kjMugr5&fTVpT@|;Sa{U>ljT_Jl!Piz?F;Zn||?O#0I#9au6a?YLFOS zggOd>#FcE&*+C+g+Sm{z>Jon@NPIF5^t&L@Ih&;gxCJY#`R~ora%5*1)e8T50WmIz z4gc%atG0`_xvZ_Bx@l`J>;A_qCXZ3GP=w_DSxH${bFk`!MfoiLulG>#=wEk0)$nSq z{&q;_hl-hF{>~7nWBFT4$uxx2Z(Q{&gVl)AqC#{o{5xaQGhAFNM9gUMsu`r#>Z6_F zGa)(CaltURIZ8BQ3ID@r{JR726x;slB>Lmd{qgh~8X9Vz$wxb`sNKumPemus zomTCDzcbCAVx%@*5vg9nZ#;9?xdn@e@vKyr7Af8v&-`>_BE==*_eYA7k zSUa?XjCMS^{hhPdu@(+2A~;mu5@|;r^;>%9PUpAIJg&}Z9wn}ztWQ_0z)i6XZfoH% zG^BERgK7iU- z3>x%)5$D)1vtVXfMzw}^ij(hzKJ3d$pO#kVG>#SxSHxH59B^RRJ+%)G4U@E!k@}lHY2)MTwzfKFS0@oX5oNWrl$LpiynpUy zdTO7?R=&K(w&5sH+7w>HJZeV%~ZQ~qSNK6nem~E zD)v`vXlFjHSU-8x9j6v&t8*-|;xU!gPKi3RyuRS)_ZTO&C<3Ow^g+`QiSk+92jakFoI*hN@(2ld>9d8GWn!9vn zb- zjhUB=w)8&cghzQqa1`wUwX=kaB}IVc5gU54w1<3}qv=Q;}kO=NJ1DOJEG~8TmgA z!n|DNeE{#cc;`wG*H9L374(MVqn+rLTz;dCdsFiS*$?zGE-g(EZC^xVs}jWU7tzrV z6T}4Kk0gjq#4GupKH8aIF^hs0`X6*J)3M3eQdHOA>8+gsnX>ne{`G6$2Fe}{rS}Hq ztx{L7r|3BaRlnU!OrCT(W!PxV(S#V@i*_iXe#=!qqp#y zidAe_AMyHBj7O!oIhBR$M)eU*rm;*P?NqLuR!v5{y>LVy9m8u3#Rtye5^qhzo=ZD- zYw^O)htBUB|8c@%<9(<{C?7DO!cNoC zBbAEW>3GADUsM}9(sob^O%@+bM~m9YV8){h3|FUb-he7-%+RS@$)fHIRN*jCw44DO zTMQCKGY}b-V(kp}n$9srbbE<~{mGzfCwcXG=gjlpUNq826l&GmQ^dxX;8^W!uiHP4 zxH_zXQ-s`q*|B|6#b?l<7=82-Ydt7vuySgctuWi3TZH%d0i_e)t-tQa`fLSqI$#~v zj`hl0eA;#L%w9sSD~w(%w1dCGUrnE||Bbg^(6J6fly~{(hKNZs@w%!V74||{aMJ?~ zYBfVyj0?G4`N#z?du20Oc;DaGR6JCvNjo&`n~qj%Sy*oaYQX#sj*5uUjuqSK`SF!c zK6(6aheK*>^Z}P&&zPNcKF!6#NI>St{XSi0b6Z6N)dazhKat=h|-QLTlUVi z>_@|sS66Fj2bv|&=@RKJaT&tr3Ns#u%PwZXQ25 zwL#L$)j7R}i`&$*OlaU8xpez$doN!3YUR>ujnd)5Z#IHpPKIdv3h2fRacwT>p$w5r z^2!V`;T0Ap-k;6fogHn;o1LblPME4fn)1LomCXd=ily*{^?#hfwi)V%B>R428m^{6n`Yga_ zQ5X=#7@se)l$W#xI}aYTHw&FvT~@M-;bgAbV|Dp$r^kEIlX&qtELJ=2DT3!Qcax`G zaq;ixhUOFPjJ(1<$s0E5T<^)2$ZKMeT}+;bLC{WuTQ+V_ko~N_02Ux@l7CTA zdO3OGD$52x=rY2E-KEq6}FR4P7TVkW#`z6f~CER1oBUZdW%!U-Z}Syx)J$e~x{4`qp#n)^e+E)%5g) zIzOexZ9A{;5;der`o~lD;{-^2@z*H+ z;|xblpXKc&QIw?2;py4V^u#g46ZLFm#*FGYA#RFNMNx7yT$%CN&MYNujLh?}C`uXV zvy+|auB6e*UDy#X!$KV_XVhnbr2I5z=1^xi${6}-JibKr zoUVSd`{irMfQW^pc^K;}=PfaHBz02xRh6|SBo80vN{r7;%5si$re|kmI^z=)U6~m( zFxJ$YUbSQ-$ES}@i_cC-j?Wybm%obg6bE;(e#mZaZC%FZUPgk(uMip3O;nhWjUuns zksb1`EA`XCDgP|&C~^~0vqn+PpMqlqAM-qL^gY+zpO0~?ku$Nr?9UiTDwYaK?K>bb zqq*A}NPm|@QXC~>-3BM6XN^WR#2+9MksUk)iQvdx14$j421yGn-H_dl{4j%8h4jUW$h{vSdw3m^7J@Tt z$n&n$L}hSG*`Y3w^^~{k=kJJxsWt6;|zTUWDZq4 zz%b|pNh1(x@LG^mfd?en{nA3t@wbrFz#&M=|Ipy8AgTO8j2^A96_7rV&a6~ddbXm( zw3GT4hWw!&#-HXQ)j2YqhP=?o*kj1UkTk@JPG?52D@$nvPK)o4+kA^>WyLj-PeFV? zMp67BQ?i0-+flH{GU79{oY$bI;Jyf12GTV&Cw+LPQ&CPrL5qhzU*c1JfOMs~vX$rDet^VG}eyw`O;d`NPu_ z(5j-mz$=!jo--9%>JGN^k)#dnCV5h7e0H`oQQ3l8$!%xW^Vu%+&Y3mhd1vNmCBD0C z#hI0n=|p`iU`O+G272-)K4Ew!jqBUc)3B&LrG6qf&0pg1gzQXLI$v4JOO5NrcbBT4 z6Z)LYss`x|li@I>nCSsY!5s-nQ`)1CTns_r#+Xf9Z1$B#liLBr~lJ$x3$r^Y8b_kdFa??RGq zF4RNYMPi(6aOObi&?rL=fuz{&2uU3Yg7i~{DY=ym#jhwx6?_X>9rB>TH$zeb>mbSV zC6LrHjJaM>l?1u$PY#jgZ$Z{WesrR=%W|fTbY?0_H1AQmLCz&i8%$~>>CjkV#0xUb~_)og4Jcj4ZK=i$zI zA@2KlKy@4QduRxis1oXHue&C;l7FI zdD%1tPhjoQGnI#RZpH(=sScO7O}$x)=X+bVF!Z>0DwAaC?v>t!zDQ`VQD`R(*#SuWPFV73KX_KLg zgrS>0`TM}A5==hA#NsPEeT6=Y-aJTJ(m zy@l0Jb;1S#xSt0E+ccjVit>!EBUe+v}&wD>8;*w7^U8IBm0Mqr7d5e3YWXWlFMhpA zv^vh8r!=*yTm5-{Q>&$HeMPbJwN0Z{QJ)7!TD4P9JcGKJK9yb#qz7QAIS!20ze4_K z2f%1-(JIEFWJ9?xVWhpH)G-Ztew0-!fU+}85PlerssWFeEIKwQfTuLGTDC#ifu}W# zR!ao(A}9t2lHz)^Xl*4@)EpupI7)jiNKpppY$NL007gNtchjN-BNFsg*dD1qy8bq- z=79}<%pQQHJZ4FlwSkY>CtzJ4vr3I+PxaW-27np0G>p>Dfeq0usUOc^q+`r-mhoVn z`PvpOOCizou~8j7kv}JmP{Bel*>&pTO)%@@3WBj|4&cFl(b^`YiXHJlbhI(8wEkdd zfMU)wO5Gg6i>y}5i+Ea(<=3szmg7kE(^HMHfTV5;QkZ|_o<%BNPg$cBB~DMhgOpQG zQDhI&Q-dC-K0->``7}3k#~>x!IEIviN@#(IR*GrHX$5Aq;Q4K=mP~|5JHEC}v}F@g z9rV<1NOjax9a>Rt@Ut!5kdUgckdkJ;7#CSI9x0idk5m_ay*OZZ<{frq}ca zQeE`a*GS0<>LQS3u~ejFRU04c?jt2jbik68C8i^V<$&IvLQ0mcgMpWJ!;zA@%}D9> zXZIi(;o;9M>AKLSxPFAf|2e~bxfM1j)3k-oD9<6ObigpVuH8SqNi|ke{ z1`Wyj5Nc}fvpl7&8xE841=*|5RLu6a{t(eIK|3Mp3G_s)b@R zr0v^6yMiSV44=~B(dvRuJf*8uJ>QAvceQHXc3Cr`6x(wW7}cQfX4*1?;X?s=m%tEX z6whGZ*f9{JXqoo5STNZ#vAJM0c=Gw^Dj2y3OL)?t%afmEv0yZ?CHZaCxdM#J={{R- zP#*QzQUhzgiN104M2h-=4=vO+#bB_K43e*#)W_@>S@)YyGdmU8b)<=H=feV zs;!3-vqrOkJ@GCWd4f+`uV$<}4}8w54a3r>KI1dBdX&1dJI{a4s(#y@7a=dahnz!< zYojRjjUGIuw^iE>B_fp`*s$rA!LA}d6i{4i2Zs8LL7fFgF6bZj+UH>5dftfoc+3I| z0)u;~rBN?4kJvCUnrgHF*VltlE%IaM`(C`rfk49cMKca1#$yzi94q=%*Z@YIkRhz} zmfN)a^x^satXf(hMS+XdGdu@>)JI=AKR`)NLy1}M>5!W< z9K!>}A~4Fsz@znh29t5syzi6y9;bmdMb(HVjOFHm*ghY#@`IjaNnmpH$nt$KIeNrO55}haxZF^% z;=FBOm_?(7^6_$jWqX6cit`GGje${G+XVTE z=FW);Fd8h$&Vs=ygLO!hv!!oh+FM{0M+h~H#9lC(AGr&tPID)sU7#5ljZztX7oa!9 z6wETT^T8S=l84p9 z=9nxePIpXwGnoetwW`OGdCE|$*4SlkQy3oA>EcDmTj}C~$yW86i>E+@r||sb7CE&P z{i`nSQZmTeKs5lvt&{{1UHbK;ECEexfb82CvMnTCPm>ntF-3|BU{BUB$Q(1FSA^Nt zCALoeB1r|X#pxGGcL0l8p$;H6b$cux{US;FSV?~;siRm1x;-qpqHFahI-L(Q_$^dO7$En{Sq-;Pm^xY=TauFT>UO3)$*#rNs`?}fLePEpzE(Bm7i=* zG!lBjDTbVC$k!q1dYYt$rs=s-5}yuGL~%n;lJsu@)ZlD`3rM;IO*Cc91<3GifUdui zrI5cww~~^|Ed@v}GxQ{>-W3LaQj*(21tGkP)GLwbde;!j|eW3 z)bIf!xJXt6z6Pk_ONP7xiFR_7Ye-N9Hw}45W+*=y{2nCjHq<&*;0{?Dl76C~j#h!h zKcy;eRF5a523Z@j9Asld9}bCsN(63|A=~SEjDJTYsG=^AWgz=PmWLb)N!S0Xr1^ic zf$HEd8x8-zkb3<6Z!|E)ZiY2lceNl2LCHbCVLG#lGK3%1}90$g9ax_?Hn@nIbRqVBq@2=kVgzX zNh)y6(0^&@Nz$r0Y3NTG`oANogI`Pg95bOyTDO;tibzt!-y57HC9fF#NlA7^M*dYu z+Ar=Hc2ASk@t=@ig7*J=hT+pBRs1XRJs?XF`EN^#zb7lEU@3=&sI3Y{gCyxg#}ktJ z>Sai8L;66{MUu+bHaJPLuVe5hC8_G+7Dk?hAwdl^Hw>SY^l@s1o+`3Imf#OFtMjs1 zrS!;cYm`8yQpbp`r^kPv z9-sC!NgrPSK0WFWjsO2XJz8jmw=LK*c3yqHcA1@x;P;j}_%F*mc-(S3%i?*<9emYt z5ALzT&YtJ-D;>P=3J?D2N;|_}XRUJZQY$_9^Q-J^H22DL@XcUj^X%+Jz7H&Ul?M;b zx3jT)biRXo=6Ue5U^zTswS(^ko3`4{Ug4*}M&*0(R%`5R9G|?#!5gmj;Mc(>@aAhB z{3O`?wRZLzF9e&k#)EfRXJ?c7+;tA#Y^?`>2sV{>T<_plz}Bv}vuXSu*qn9nVS}B$ z!Sgma__OQb!$v!s!TW85e_-3eIM?2Ve;Yh_+Pii(i*EtzyU~OD71){Jt^)Y?t_S}d zY%ceD5B`CTeb3I`;rqan3*g`Tb~c}nejon52minp@_-NEAK0`H>})YV^?`#e;bEH` zY$>0N`!aqJ_vO6#W(QlrXW)(>_i|8$-vZY6Gt9|8J3GN$`!FYaF(+WB zxYvHn3E0^Ec6NsE154hAIXPfwXZh#@n3MgO6R`6<;2`D%Y}!FPyU0(0jXHps``pgH z;gdf{%pF9`fnDa!4?-eg7%>O7_OP8@ z=l8(od;$NC*x5~^ARr6#jv22m66*$Kc-)_;<|C?(!{QeUHMwFYWAS z?)nn`9fN;h_qf+r@DFV4S9bMRh425W4^RFQ4j#9w4;4Pp&?DW@JV3SV5#nX1C@wunr;wiWY zR)%*x0~f*8p0P6zeh+NUX}I{c9e-|<_cdHR0~gQQSw-IOEL;TJ4py0K=iuVkaPgd- zRpnd2`ksZ0=k2UIcb$ie=inlk7x%gV7s19}urnXN4=njSJiKUUeth&rOyLDM2v(B^ zT*4HBO}k`gwfQNqQ5WIgH+EK+PyWWi>hX)X`}5}CI#_)^1NR2J5ch_>^<@VO;B#>g zz|`kQd#srofoZ3V*3FtjX$2v&UQI7v-}Gd+`puHb&HUHD@k8%%|8Oq zgRVR(_0cQ!F#kl`iC@;rK>Y=q^%iSlS=tuwZysBTYmAk$gm;wPX{?vr6jXv`Fm+;& zO$*#u8&xgUAHKAqZd5b>vNa*q6_SFl%GCU^*iF`w>?pImhA;C}^T%w{$w}^wO%np|ct~ zdOL&P81>6$=;*aZRn(`_%dKY&CA|@AF71@ohK^qOY(<)`HinMgz_kO&zOA9d^ePbf1p0l0B8sV0D(XwAP5KsLI4e*xubW7^y2Xva2>b-+yrg`Iigi} z=A*ra5D*ZpbkI*Q4jD3C=ddG zK%fy21Ox-2Ko~$lfz?4jT+>g|^d}wq4;(a%-GK<83D6XX1fm#br5O^deum|`UC;&D9tARWq90L*o(5qy6S6LaLm#7bbhrn;ZBY@_Z z0V+@ea04uWJMaVYf25xW?jms!_!gjV1N2kgEZ{4oj{_$FdPjW}NP>PSkPOg^?#}^w zA-)fwH}nO-djP%de;qa(Ay)%y0Q$*c1+Wsx2OP+MoBlZS9VF%hi-76C3}6y44#)+@ z0`#-$NFW0k0b~MEKr?Z+8>=o-yR&-YK{wVcr!Q*m55xikfH+_vFbEh7!~;Ws1RxP` z0!hG1R8|I(-m`oJYy{Q-^a_|>`}zX40D7r?8TbyEgZ$Y5y`iUHzK_vx(rZK)um#Ew z0b2j>1M7hez+~ht1ttPnz%U>cNCVmeF+h8O_5=Fe9|NR31JJu>dP7gI!szF8`u*Yq zU<%UoMv>l1I%z}dj6@fpH9+5QKLU0DyMWyQ{jxF@d9;zxe())HJme6dAFv*v-EAJ^ zn*jZ0-3g#gYa47S{VYIxPIsUO&GWQeVu)giVu)giMjgIxTHTZRt2q=^G?tW0X__CB#76;R0NO9m5oJ6;t(Y~vf^;rG zuDlGG`f*6V`UEFE%@xhRX+zPDbdKI7no!e7AWakg20+n9>yp+d2c`itff)eJ6RkJn z!|;(=F*QVXPt((I{a%l*Qvqs>GR#i=S(+OEvplMUIz{<({hba88agUydPZwY7XN(( ze{7gKZ8l;$K%+(V{HK@FNs4Y7U2|k<6UupVSpTf|Pc}r2{c(z@BATX!z?1cv)3gBk zKbfk4-+5tnfQD`ZU;`Edv_CYyjzN0W|?Xpa#$y`qEUW4zNfOo%*vHISr7m5BLN1fVu!h zbW6xEARGt8KMVO+B7u^eL?fJw4$>0JIl3g^UEE0eZ;k2OT-sA7}=Shn}3r z6;bd}qpbleU{-Dfjvg0g<@7;7ouQz82CxB7c9`rapE^%2v z7ZS;RaKjZjLkx*!_36W8(`&KpAS;Q_0=h*1IJQJo8^}W0SRPQjc z0g@?V-$2%`^qVMvP|Urm3eQ2Tv-+zlk_NGGAM<}#huN0-zcj9*x5@@aghYgdVzr5a zL97e&5^ON@!V~h06jrT9Q#@ay7v)gXOYU*+w0eDcx?UobO0*E&$h4iPos43y4rbo0 zo0t!kkNJPeee!b7ulxRetk{7~LPA5r@Q@~sP?<5}Dv3$L63@E$nEzS+ovNFPyeDiM z2J=vKDjcyU(&AYJ^An3<$3n!-c;?mAJdI%aPoolhmD}S1^Dxwo9?_qGd7!U7Cm&x| zqi?6u#qD~CRzp~$x_w|!76yM_#WFE<2s|)OD9GLE?eD&I(BKQGDI8tGcx)FtP*c-A z?(#n{UVtov8Yfg3keWK4&C@#*M!uSdAl&+7Y{tA32al;NP}EFdUUki)=BWt%cHFSc zJ9oXI-iAI(?L{mMea%x57Ua*oTCM8Q2{32|M=>tvONloVm}gV-T!e?IY(qw!nA5Om z8WKhp(>47IQ0_1HUrgTJ_pRkBdjXp2=;;D+7M1&K2c>yU?Dz5R1?3l6RJID5s?Zz| z6%#ScS3qen!%E-28rZ&yo65d~rV2FXVF_Iu?_T1y|KloXm8KUHFiZ?WQMN!_Ok~w+ zLSY`@(4Wma)Bfj$8F~kGA0Lz!>k^r#k9nlSn&DyZrQS$b01Lz;Ri@YCrQRwd9+07V zw!@LW`G-6HxbU*dUJiq=P4GY_8av_9QwKwDOS;TPim|Y3YM%MfZ%<^;qE7d&p+yWT zwP+qIaV@cb@3Sp;#vrFTaxk36pqa-(bbmQ@+t(Wl*1#Z2c6N?<t)w%L~=8cQ6DymvAi%4Uh}$#6A5+$Lo!QO3nyVY@_F zDyvcpUBss9jU66w(&~qu8~0aenv7~`l#uhX*yTc*!{Q8y9pbKwMKm?fZkU?**2Zn8 z_nn71W`jn3ny(Dv9YOD$j_&Q*N$(%lA?;RiDd>Z~O+~P-;?orDBmY?u)q^#wlo!Fn z&|s|SFbsC)krE3gZf(2jVYwBEBxBXg5bKAb+92@_?4(a#te3E-BE0^5Bb2_ohf*xg z6%|LYDz(ESFx;1G%T2g!{^7$d+~(0ZU=*-aa9V`uo`zj}dL5CH#==#D`}Fr|(Z)o9xva z45xuN&ym>ADxU7P`U(YP81^&djHxfI!_k$A^+n&|=!#L7uX(~mK}?MXtB&SS{9$9E z_}kV%?0mvfl+9q3w77=ye?UwBONLXVK58i1WU#<`W?msa-LQq^zUUc!-}TyaNfnrx zvnoKmnNi%h+Bs0117~_|Uh2(25j28%`l^lOru+RAp8;%8{i-UXjUVBl_%;%Q$-+Et zVnMmoBa=F9ohdEgrqZpEu)YL8Ga89?WN98?G1Gtc2VZq;?I^Zf&`3-l3Cm55M93(} z6OF`9D*3RH2+f475hTiuWdrNB3X&V@f4XJn7~Pi@{`~?XH;WDa`_&u1ix$tbAno@} zi*=*^md@}s=5G{NjpoeZ5QoRG_J6;#|8y+Oxqsa3A7^JjqSNSqFmb--*(W{~di^r~-KCR?L(mBLztQr(!HHSEJ$q?B&;v9Ty;u>*!ePc>iH4k67lO1A~A=BN1BJsY^_qQQrsJJUv*>j*-yKx zehv*b$@d>!yHnbGzkhK~SQBwD2UBgHT;ra7G%@wi4{EVRdXw3?_!uycx@mUn#ct&) zymqA6z&s3R>KhN|ZK_+bs5s|P6Va8*n#bovHCWp${MWJaRt9m8Wwgd&e;$xTE2>&nWqA!4qo!;_>3*Bi!Dw^iwsyqn&$|8 zKRf%*V%tw!i!JECX403uNp5SB2UTn_syL@|OYsqvHP0s6b$Q0B%GEpkTx?;UUbOe8 z+?p>f>&=UEy0jE!$Khjca7$5t975GR6DDh0@73$n^0)CI&_q56n5V{EoOC98W7qwq z^qfdqrM@v@I_iit&!eek>F3wgZ`?kWH4AAPNnviDT+^y+#%R9@yIZ0xLIDr-5j|qW z*C^}zCl7zk6lHjfs4$*&X46F6c-BRo7$eqai!tIilGkEH_(aJ2 zF(L`l$2{36{a*85x}@~vr+VH0Gr?Y9|g&#ACjBwo9kXKvpKbB ze7lKhCwyPSXzyt!B41EpC?-wI<>Hf&A*Jws=-NHwlfI zCok=)6n%G4=a~oe{)f}Uxq0?dpB@Lg##eanGvr`mX=-EbV%sFv+1EUUsqsqd&hI7+ z8LOAozrkGWEb2|hZaB4zSTPwBsSjE;p-o|a>g+C};S`pnhISKgPQk#$cNh6nF!X1- zi*v-~TvinxQ(4v0sXgQaq1safO_j!ji)CllSgf6j*@){U_D)3|TVlimWc!-ODUG*% z>C@@(-VW#)4I%m-`kV-T9q&te^bs$=j(FHEHonf@Q6n6p>olF}vuksRdDBqO1&92K zyt;PZCHE&hs)u^$V+!?LcZkDeVIHt_wQMb~GOzlF!@~G>3qRbZvp}_9KM^{edGZa{^d!X1xjGK(U(y3%%zB#31fE6Uxe{Y!YeHLif*3D)M4j9@nY3R znR*%e2BJ*qFBYM8^NXDRovn)aembie`JdG-kCoM#hc;b}j*s6L-c{4xY7!FJq}iTW z(ee$9^RZab=M9X9{C?>B$39nJ)&cTSKQT^x1Vdl*Sf=**kMevswkeM)vE1o9-nuwZ z_DzhGd5}|H^H~`^z3wb@!zVsI6Y+_EFit!Ji^y-|tUOOj$!&e~{KxZRSBx&UFi)KF^IllEdTXs3#W_O;im$0><{4FK zT_elBn$=}evBk`RqQVSVtQja@D;?-F`}3n;zB;H1dDoQUVDk{MZ59UX{+d9+o@t}nij5pbi8$|6Jb zZ*hvzS7TFbY{9o&V|x>BvzWKhnLssWsOXGNJ$C1ETagYPX&#cbx3I~Y=Qo_A_bvMC zfN;e;Mr*;yZNaXi7K+=bzZ$og$90?I7&KJ$osE@k4#=|E_<9_XAU4ltwS3Lfwzj|i zWBV#MNAJNKID`{jHC^II6su>RzoiXtxu>Yd+gtTF0pXGOBA+`$zKVTjCjCK26){k- zpo-b?uGANB23?2PqxEkz+Sp*JSS(mgwp8qbsJ#$xO6gVLncgohbF8lAl@CrY8(*p3 zyWFEHP0oEF*c@i_dDup($2V(wkc}$$TgFV;(l-y-rr0^Gh>3x3v*1nh-)19gZCbjO T+1aM|w&Uf+rdm6gt^NN2+&jKc diff --git a/frontend/index.html b/frontend/index.html index 990e82d..c4d351b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Meiro
diff --git a/frontend/package.json b/frontend/package.json index 0b902f3..56563a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,11 +20,13 @@ "@tanstack/react-virtual": "^3.0.4", "@tanstack/router-devtools": "^1.16.0", "@tanstack/router-vite-plugin": "^1.16.1", + "@types/react-helmet-async": "^1.0.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.330.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet-async": "^2.0.4", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b32ae56..a279967 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./react-query"; +import { HelmetProvider } from "react-helmet-async"; const router = createRouter({ routeTree, context: { queryClient } }); @@ -20,9 +21,11 @@ if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - - - + + + + + , ); } diff --git a/frontend/src/routes/attributes.attribute.tsx b/frontend/src/routes/attributes.attribute.tsx index fc1d848..ab14acc 100644 --- a/frontend/src/routes/attributes.attribute.tsx +++ b/frontend/src/routes/attributes.attribute.tsx @@ -2,6 +2,7 @@ import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; import { AttributePage } from "@/pages/attribute"; import { attributeQueryOptions } from "@/pages/attribute/api"; import { createFileRoute } from "@tanstack/react-router"; +import { Helmet } from "react-helmet-async"; import { z } from "zod"; export const Route = createFileRoute("/attributes/attribute")({ @@ -11,6 +12,13 @@ export const Route = createFileRoute("/attributes/attribute")({ loaderDeps: ({ search: { attributeId } }) => ({ attributeId }), loader: async ({ context: { queryClient }, deps }) => queryClient.ensureQueryData(attributeQueryOptions(deps.attributeId)), - component: AttributePage, + component: () => ( + <> + + Attribute Detail + + + + ), errorComponent: RouterErrorFallaback, }); diff --git a/frontend/src/routes/attributes.index.tsx b/frontend/src/routes/attributes.index.tsx index c4206ac..ca6b72e 100644 --- a/frontend/src/routes/attributes.index.tsx +++ b/frontend/src/routes/attributes.index.tsx @@ -2,6 +2,7 @@ import { RouterErrorFallaback } from "@/components/RouterErrorFallback"; import { Attributes, attributesQueryOptions } from "@/pages"; import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; +import { Helmet } from "react-helmet-async"; export const Route = createFileRoute("/attributes/")({ validateSearch: z.object({ @@ -23,6 +24,13 @@ export const Route = createFileRoute("/attributes/")({ return data; }, - component: Attributes, + component: () => ( + <> + + Attributes + + + + ), errorComponent: RouterErrorFallaback, }); diff --git a/frontend/src/routes/index.lazy.tsx b/frontend/src/routes/index.lazy.tsx index b1e03d1..e212a6f 100644 --- a/frontend/src/routes/index.lazy.tsx +++ b/frontend/src/routes/index.lazy.tsx @@ -1,4 +1,5 @@ import { createLazyFileRoute } from "@tanstack/react-router"; +import { Helmet } from "react-helmet-async"; export const Route = createLazyFileRoute("/")({ component: Index, @@ -6,8 +7,13 @@ export const Route = createLazyFileRoute("/")({ function Index() { return ( -
-

Welcome Home!

-
+ <> + + Meiro + +
+

Welcome Home!

+
+ ); } From 0d61f0921159387e92eb0b543d79a77af4687545 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Sat, 17 Feb 2024 13:08:50 +0100 Subject: [PATCH 15/17] small refactors --- .../navigation-menu/RootNavigationMenu.tsx | 8 ++- .../src/components/ui/navigation-menu.tsx | 10 +-- frontend/src/index.css | 67 +++++++++---------- frontend/src/lib/api.service.ts | 12 ++++ frontend/src/pages/attribute/api.ts | 5 +- frontend/src/pages/attributes/api.ts | 38 +++-------- frontend/src/react-query/common-api.ts | 5 +- 7 files changed, 68 insertions(+), 77 deletions(-) create mode 100644 frontend/src/lib/api.service.ts diff --git a/frontend/src/components/navigation-menu/RootNavigationMenu.tsx b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx index a2887b0..034abeb 100644 --- a/frontend/src/components/navigation-menu/RootNavigationMenu.tsx +++ b/frontend/src/components/navigation-menu/RootNavigationMenu.tsx @@ -31,11 +31,15 @@ const NAVIGATION_CONFIG: NavigationConfig[] = [ export const RootNavigationMenu = () => { return (
- + {NAVIGATION_CONFIG.map(({ title, route, icon }) => ( - +
{icon} {title} diff --git a/frontend/src/components/ui/navigation-menu.tsx b/frontend/src/components/ui/navigation-menu.tsx index 43229f2..1e2a0fc 100644 --- a/frontend/src/components/ui/navigation-menu.tsx +++ b/frontend/src/components/ui/navigation-menu.tsx @@ -11,10 +11,7 @@ const NavigationMenu = React.forwardRef< >(({ className, children, ...props }, ref) => ( {children} @@ -29,10 +26,7 @@ const NavigationMenuList = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/frontend/src/index.css b/frontend/src/index.css index 942501b..2ada6b8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,47 +5,47 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 346.8 77.2% 49.8%; - --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 346.8 77.2% 49.8%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { - --background: 20 14.3% 4.1%; - --foreground: 0 0% 95%; - --card: 24 9.8% 10%; - --card-foreground: 0 0% 95%; - --popover: 0 0% 9%; - --popover-foreground: 0 0% 95%; - --primary: 346.8 77.2% 49.8%; - --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 15%; - --muted-foreground: 240 5% 64.9%; - --accent: 12 6.5% 15.1%; - --accent-foreground: 0 0% 98%; + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 346.8 77.2% 49.8%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9; } } @@ -57,4 +57,3 @@ @apply bg-background text-foreground; } } - diff --git a/frontend/src/lib/api.service.ts b/frontend/src/lib/api.service.ts new file mode 100644 index 0000000..64154eb --- /dev/null +++ b/frontend/src/lib/api.service.ts @@ -0,0 +1,12 @@ +const BASE_URL = "http://localhost:3000"; + +export const api = { + get(endpoint: string) { + return fetch(`${BASE_URL}${endpoint}`, { method: "GET" }); + }, + delete(endpoint: string) { + return fetch(`${BASE_URL}${endpoint}`, { + method: "DELETE", + }); + }, +}; diff --git a/frontend/src/pages/attribute/api.ts b/frontend/src/pages/attribute/api.ts index 8fe3444..9cdb74b 100644 --- a/frontend/src/pages/attribute/api.ts +++ b/frontend/src/pages/attribute/api.ts @@ -1,12 +1,11 @@ +import { api } from "@/lib/api.service"; import { AttributeType } from "@/types/attributes"; import { queryOptions } from "@tanstack/react-query"; export const ATTRIBUTE_QUERY_KEY = "attribute"; export const fetchAttributeById = async (attributeId: string) => { - const url = new URL(`http://localhost:3000/attributes/${attributeId}`); - - const response = await fetch(url.toString()); + const response = await api.get(`/attributes/${attributeId}`); if (!response.ok) { throw new Error("Network response was not ok"); diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts index aeb6627..e025150 100644 --- a/frontend/src/pages/attributes/api.ts +++ b/frontend/src/pages/attributes/api.ts @@ -1,36 +1,33 @@ import { AttributeQuery } from "@/types/attributes"; import { infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query"; -import { AttributesQueryOptions } from "./AttributesPage.types"; +import { AttributesQueryOptions as AttributesQueryDeps } from "./AttributesPage.types"; +import { api } from "@/lib/api.service"; export const ATTRIBUTES_QUERY_KEY = "attributes"; const DEFAULT_LIMIT = 10; export const fetchAttributes = async ({ pageParam, - opts, + deps, }: { pageParam: unknown; - opts: AttributesQueryOptions; + deps: AttributesQueryDeps; }) => { - const url = new URL("http://localhost:3000/attributes"); - const params = new URLSearchParams({ offset: typeof pageParam === "number" ? pageParam.toString() : "0", limit: DEFAULT_LIMIT.toString(), }); - for (const key in opts) { - if (Object.prototype.hasOwnProperty.call(opts, key)) { - const value = (opts as Record)[key]; + for (const key in deps) { + if (Object.prototype.hasOwnProperty.call(deps, key)) { + const value = (deps as Record)[key]; if (value) { params.set(key, encodeURIComponent(value)); } } } - url.search = params.toString(); - - const response = await fetch(url.toString()); + const response = await api.get(`/attributes?${params.toString()}`); if (!response.ok) { throw new Error("Network response was not ok"); @@ -39,10 +36,10 @@ export const fetchAttributes = async ({ return response.json(); }; -export const attributesQueryOptions = (opts: AttributesQueryOptions) => +export const attributesQueryOptions = (deps: AttributesQueryDeps) => infiniteQueryOptions({ - queryKey: [ATTRIBUTES_QUERY_KEY, opts], - queryFn: ({ pageParam }) => fetchAttributes({ pageParam, opts }), + queryKey: [ATTRIBUTES_QUERY_KEY, deps], + queryFn: ({ pageParam }) => fetchAttributes({ pageParam, deps }), initialPageParam: 0, getNextPageParam: (lastPage) => { const nextOffset = lastPage.meta.offset + lastPage.meta.limit; @@ -51,16 +48,3 @@ export const attributesQueryOptions = (opts: AttributesQueryOptions) => }, placeholderData: keepPreviousData, }); - -/*DLETE QUERY*/ -export const deleteAttribute = async (id: string) => { - const response = await fetch(`http://localhost:3000/attributes/${id}`, { - method: "DELETE", - }); - - if (!response.ok) { - throw new Error("Network response was not ok"); - } - - return response.json(); -}; diff --git a/frontend/src/react-query/common-api.ts b/frontend/src/react-query/common-api.ts index eb90dab..d20827f 100644 --- a/frontend/src/react-query/common-api.ts +++ b/frontend/src/react-query/common-api.ts @@ -1,13 +1,12 @@ import { useMutation } from "@tanstack/react-query"; import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/api.service"; /* * API + Query to delete attribute by id */ const deleteAttribute = async (id: string) => { - const response = await fetch(`http://localhost:3000/attributes/${id}`, { - method: "DELETE", - }); + const response = await api.delete(`/attributes/${id}`); if (!response.ok) { throw new Error("Network response was not ok"); From 4e14379c4e59a0202c1c8fdbfb3375d8aedfdbb0 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Sat, 17 Feb 2024 13:26:34 +0100 Subject: [PATCH 16/17] feat/ change router to accept route of attribute/attributeId --- .../src/pages/attribute/AttributePage.tsx | 4 +- .../src/pages/attributes/AttributesPage.tsx | 4 +- frontend/src/routeTree.gen.ts | 54 +++++++++---------- ...ribute.tsx => attributes.$attributeId.tsx} | 13 +++-- 4 files changed, 37 insertions(+), 38 deletions(-) rename frontend/src/routes/{attributes.attribute.tsx => attributes.$attributeId.tsx} (60%) diff --git a/frontend/src/pages/attribute/AttributePage.tsx b/frontend/src/pages/attribute/AttributePage.tsx index 3192ba3..c3d8c0c 100644 --- a/frontend/src/pages/attribute/AttributePage.tsx +++ b/frontend/src/pages/attribute/AttributePage.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { Route } from "@/routes/attributes.attribute"; +import { Route } from "@/routes/attributes.$attributeId"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; import { attributeQueryOptions } from "./api"; @@ -9,7 +9,7 @@ import { queryClient, useDeleteAttributeByIdQuery } from "@/react-query"; import { ATTRIBUTES_QUERY_KEY } from "../attributes"; export const AttributePage = () => { - const { attributeId } = Route.useSearch(); + const { attributeId } = Route.useParams(); const { data } = useSuspenseQuery(attributeQueryOptions(attributeId)); const router = useRouter(); diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index a0f9009..2944a68 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -64,8 +64,8 @@ export const Attributes = () => { } onRowClick={(row) => { navigate({ - to: "/attributes/attribute", - search: { attributeId: row.original.id }, + to: "/attributes/$attributeId", + params: { attributeId: row.original.id }, }); }} onSort={handleSort} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index a5cea45..90d2066 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -8,51 +8,51 @@ // This file is auto-generated by TanStack Router -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router"; // Import Routes -import { Route as rootRoute } from './routes/__root' -import { Route as AttributesIndexImport } from './routes/attributes.index' -import { Route as AttributesAttributeImport } from './routes/attributes.attribute' +import { Route as rootRoute } from "./routes/__root"; +import { Route as AttributesIndexImport } from "./routes/attributes.index"; +import { Route as AttributesAttributeIdImport } from "./routes/attributes.$attributeId"; // Create Virtual Routes -const IndexLazyImport = createFileRoute('/')() +const IndexLazyImport = createFileRoute("/")(); // Create/Update Routes const IndexLazyRoute = IndexLazyImport.update({ - path: '/', + path: "/", getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) +} as any).lazy(() => import("./routes/index.lazy").then((d) => d.Route)); const AttributesIndexRoute = AttributesIndexImport.update({ - path: '/attributes/', + path: "/attributes/", getParentRoute: () => rootRoute, -} as any) +} as any); -const AttributesAttributeRoute = AttributesAttributeImport.update({ - path: '/attributes/attribute', +const AttributesAttributeIdRoute = AttributesAttributeIdImport.update({ + path: "/attributes/$attributeId", getParentRoute: () => rootRoute, -} as any) +} as any); // Populate the FileRoutesByPath interface -declare module '@tanstack/react-router' { +declare module "@tanstack/react-router" { interface FileRoutesByPath { - '/': { - preLoaderRoute: typeof IndexLazyImport - parentRoute: typeof rootRoute - } - '/attributes/attribute': { - preLoaderRoute: typeof AttributesAttributeImport - parentRoute: typeof rootRoute - } - '/attributes/': { - preLoaderRoute: typeof AttributesIndexImport - parentRoute: typeof rootRoute - } + "/": { + preLoaderRoute: typeof IndexLazyImport; + parentRoute: typeof rootRoute; + }; + "/attributes/$attributeId": { + preLoaderRoute: typeof AttributesAttributeIdImport; + parentRoute: typeof rootRoute; + }; + "/attributes/": { + preLoaderRoute: typeof AttributesIndexImport; + parentRoute: typeof rootRoute; + }; } } @@ -60,8 +60,8 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren([ IndexLazyRoute, - AttributesAttributeRoute, + AttributesAttributeIdRoute, AttributesIndexRoute, -]) +]); /* prettier-ignore-end */ diff --git a/frontend/src/routes/attributes.attribute.tsx b/frontend/src/routes/attributes.$attributeId.tsx similarity index 60% rename from frontend/src/routes/attributes.attribute.tsx rename to frontend/src/routes/attributes.$attributeId.tsx index ab14acc..334a2d0 100644 --- a/frontend/src/routes/attributes.attribute.tsx +++ b/frontend/src/routes/attributes.$attributeId.tsx @@ -5,13 +5,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { Helmet } from "react-helmet-async"; import { z } from "zod"; -export const Route = createFileRoute("/attributes/attribute")({ - validateSearch: z.object({ - attributeId: z.string(), - }).parse, - loaderDeps: ({ search: { attributeId } }) => ({ attributeId }), - loader: async ({ context: { queryClient }, deps }) => - queryClient.ensureQueryData(attributeQueryOptions(deps.attributeId)), +export const Route = createFileRoute("/attributes/$attributeId")({ + parseParams: (params) => ({ + attributeId: z.string().parse(params.attributeId), + }), + loader: async ({ context: { queryClient }, params }) => + queryClient.ensureQueryData(attributeQueryOptions(params.attributeId)), component: () => ( <> From d47e05ad258fcfddd2d0a3fb8d76188d62095e66 Mon Sep 17 00:00:00 2001 From: Allan Konecny Date: Mon, 19 Feb 2024 14:11:46 +0100 Subject: [PATCH 17/17] feat/ labels mapping to ids --- .../src/pages/attribute/AttributeCard.tsx | 2 +- .../src/pages/attribute/AttributePage.tsx | 14 +++++- frontend/src/pages/attribute/api.ts | 9 +++- .../src/pages/attributes/AttributesPage.tsx | 26 ++++++++-- .../AttributesTables.config.tsx | 3 +- frontend/src/pages/attributes/api.ts | 9 +++- frontend/src/react-query/common-api.ts | 31 +++++++++++- frontend/src/routeTree.gen.ts | 50 +++++++++---------- .../src/routes/attributes.$attributeId.tsx | 9 +++- frontend/src/routes/attributes.index.tsx | 5 +- frontend/src/types/attributes.ts | 1 + frontend/src/types/labels.ts | 15 ++++++ 12 files changed, 133 insertions(+), 41 deletions(-) create mode 100644 frontend/src/types/labels.ts diff --git a/frontend/src/pages/attribute/AttributeCard.tsx b/frontend/src/pages/attribute/AttributeCard.tsx index 29d2c13..5d2bd9c 100644 --- a/frontend/src/pages/attribute/AttributeCard.tsx +++ b/frontend/src/pages/attribute/AttributeCard.tsx @@ -24,7 +24,7 @@ export const AttributeCard = ({ attribute, onDelete }: Props) => {

Labels

-

{attribute.labelIds.join(", ")}

+

{(attribute.labels || attribute.labelIds).join(", ")}

Created At

diff --git a/frontend/src/pages/attribute/AttributePage.tsx b/frontend/src/pages/attribute/AttributePage.tsx index c3d8c0c..94e72b6 100644 --- a/frontend/src/pages/attribute/AttributePage.tsx +++ b/frontend/src/pages/attribute/AttributePage.tsx @@ -7,12 +7,22 @@ import { AttributeCard } from "./AttributeCard"; import { ArrowLeftIcon } from "lucide-react"; import { queryClient, useDeleteAttributeByIdQuery } from "@/react-query"; import { ATTRIBUTES_QUERY_KEY } from "../attributes"; +import { labelsQueryOptions } from "@/react-query"; +import { useMemo } from "react"; export const AttributePage = () => { const { attributeId } = Route.useParams(); - const { data } = useSuspenseQuery(attributeQueryOptions(attributeId)); + const { data: labels } = useSuspenseQuery(labelsQueryOptions); + const { data } = useSuspenseQuery(attributeQueryOptions(attributeId, labels)); const router = useRouter(); + const attribute = useMemo(() => { + data.data.labels = data.data.labelIds.map( + (id) => labels.data.find((label) => label.id === id)?.name, + ); + return data.data; + }, [data.data, labels.data]); + const { mutate } = useDeleteAttributeByIdQuery(() => { router.history.back(); queryClient.invalidateQueries({ @@ -27,7 +37,7 @@ export const AttributePage = () => { Back to Attributes
- +
); }; diff --git a/frontend/src/pages/attribute/api.ts b/frontend/src/pages/attribute/api.ts index 9cdb74b..b32d0f3 100644 --- a/frontend/src/pages/attribute/api.ts +++ b/frontend/src/pages/attribute/api.ts @@ -1,5 +1,6 @@ import { api } from "@/lib/api.service"; import { AttributeType } from "@/types/attributes"; +import { LabelsQuery } from "@/types/labels"; import { queryOptions } from "@tanstack/react-query"; export const ATTRIBUTE_QUERY_KEY = "attribute"; @@ -14,8 +15,12 @@ export const fetchAttributeById = async (attributeId: string) => { return response.json(); }; -export const attributeQueryOptions = (attributeId: string) => +export const attributeQueryOptions = ( + attributeId: string, + labels?: LabelsQuery, +) => queryOptions<{ data: AttributeType }>({ - queryKey: [ATTRIBUTE_QUERY_KEY, attributeId], + queryKey: [ATTRIBUTE_QUERY_KEY, attributeId, labels], queryFn: () => fetchAttributeById(attributeId), + enabled: !!labels, }); diff --git a/frontend/src/pages/attributes/AttributesPage.tsx b/frontend/src/pages/attributes/AttributesPage.tsx index 2944a68..5c1ed21 100644 --- a/frontend/src/pages/attributes/AttributesPage.tsx +++ b/frontend/src/pages/attributes/AttributesPage.tsx @@ -1,19 +1,37 @@ import { Input } from "@/components/ui/input"; -import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import { + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; import { attributesQueryOptions } from "./api"; import { useNavigate } from "@tanstack/react-router"; import { Route } from "@/routes/attributes.index"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useDebouncedState } from "@/lib/debounce-state.hook"; import { AttributesTable } from "./AttributesTable/AttributesTable"; import { AttributesQueryOptions } from "./AttributesPage.types"; import { AttributeType } from "@/types/attributes"; +import { labelsQueryOptions } from "@/react-query"; export const Attributes = () => { const navigate = useNavigate({ from: Route.fullPath }); const { searchText, sortBy, sortDir } = Route.useSearch(); + const { data: labels } = useSuspenseQuery(labelsQueryOptions); const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = - useSuspenseInfiniteQuery(attributesQueryOptions(Route.useLoaderDeps())); + useSuspenseInfiniteQuery( + attributesQueryOptions(Route.useLoaderDeps(), labels), + ); + + const attributes = useMemo(() => { + return data.pages.flatMap((page) => { + page.data.forEach((attribute) => { + attribute.labels = attribute.labelIds.map( + (id) => labels.data.find((label) => label.id === id)?.name, + ); + }); + return page.data; + }); + }, [data.pages, labels.data]); const { debounced: [searchDraft, setSearchDraft], @@ -69,7 +87,7 @@ export const Attributes = () => { }); }} onSort={handleSort} - data={data.pages.flatMap((d) => d.data)} + data={attributes} fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} hasNextPage={hasNextPage} diff --git a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx index a5dd7c6..1865d63 100644 --- a/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx +++ b/frontend/src/pages/attributes/AttributesTable/AttributesTables.config.tsx @@ -12,7 +12,8 @@ export const getColumns = ( }, { header: "Labels", - cell: ({ row }) => row.original.labelIds.join(", "), + cell: ({ row }) => + (row.original.labels || row.original.labelIds).join(", "), }, { accessorKey: "createdAt", diff --git a/frontend/src/pages/attributes/api.ts b/frontend/src/pages/attributes/api.ts index e025150..554aaf4 100644 --- a/frontend/src/pages/attributes/api.ts +++ b/frontend/src/pages/attributes/api.ts @@ -2,6 +2,7 @@ import { AttributeQuery } from "@/types/attributes"; import { infiniteQueryOptions, keepPreviousData } from "@tanstack/react-query"; import { AttributesQueryOptions as AttributesQueryDeps } from "./AttributesPage.types"; import { api } from "@/lib/api.service"; +import { LabelsQuery } from "@/types/labels"; export const ATTRIBUTES_QUERY_KEY = "attributes"; const DEFAULT_LIMIT = 10; @@ -36,9 +37,12 @@ export const fetchAttributes = async ({ return response.json(); }; -export const attributesQueryOptions = (deps: AttributesQueryDeps) => +export const attributesQueryOptions = ( + deps: AttributesQueryDeps, + labels: LabelsQuery, +) => infiniteQueryOptions({ - queryKey: [ATTRIBUTES_QUERY_KEY, deps], + queryKey: [ATTRIBUTES_QUERY_KEY, deps, labels], queryFn: ({ pageParam }) => fetchAttributes({ pageParam, deps }), initialPageParam: 0, getNextPageParam: (lastPage) => { @@ -47,4 +51,5 @@ export const attributesQueryOptions = (deps: AttributesQueryDeps) => return lastPage.meta.hasNextPage ? nextOffset : undefined; }, placeholderData: keepPreviousData, + enabled: !!labels, }); diff --git a/frontend/src/react-query/common-api.ts b/frontend/src/react-query/common-api.ts index d20827f..342ca5a 100644 --- a/frontend/src/react-query/common-api.ts +++ b/frontend/src/react-query/common-api.ts @@ -1,6 +1,7 @@ -import { useMutation } from "@tanstack/react-query"; +import { queryOptions, useMutation } from "@tanstack/react-query"; import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/api.service"; +import { LabelsQuery } from "@/types/labels"; /* * API + Query to delete attribute by id @@ -34,3 +35,31 @@ export const useDeleteAttributeByIdQuery = (onSuccess?: () => void) => { /* * END of attribute delete query */ + +/* + * Fetch all labels + */ +const fetchAllLabels = async () => { + let offset = 0; + const limit = 10; + const response = await api.get(`/labels?offset=${offset}&limit=${limit}`); + const data = (await response.json()) as LabelsQuery; + + while (data.meta.hasNextPage) { + offset += limit; + const res = await api.get(`/labels?offset=${offset}&limit=${limit}`); + const jsonRes = (await res.json()) as LabelsQuery; + data.meta = jsonRes.meta; + data.data.push(...jsonRes.data); + } + + return data; +}; + +export const labelsQueryOptions = queryOptions({ + queryKey: ["labels"], + queryFn: fetchAllLabels, +}); +/* + * END of fetch all albels + */ diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 90d2066..9f49a1b 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -8,51 +8,51 @@ // This file is auto-generated by TanStack Router -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from '@tanstack/react-router' // Import Routes -import { Route as rootRoute } from "./routes/__root"; -import { Route as AttributesIndexImport } from "./routes/attributes.index"; -import { Route as AttributesAttributeIdImport } from "./routes/attributes.$attributeId"; +import { Route as rootRoute } from './routes/__root' +import { Route as AttributesIndexImport } from './routes/attributes.index' +import { Route as AttributesAttributeIdImport } from './routes/attributes.$attributeId' // Create Virtual Routes -const IndexLazyImport = createFileRoute("/")(); +const IndexLazyImport = createFileRoute('/')() // Create/Update Routes const IndexLazyRoute = IndexLazyImport.update({ - path: "/", + path: '/', getParentRoute: () => rootRoute, -} as any).lazy(() => import("./routes/index.lazy").then((d) => d.Route)); +} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) const AttributesIndexRoute = AttributesIndexImport.update({ - path: "/attributes/", + path: '/attributes/', getParentRoute: () => rootRoute, -} as any); +} as any) const AttributesAttributeIdRoute = AttributesAttributeIdImport.update({ - path: "/attributes/$attributeId", + path: '/attributes/$attributeId', getParentRoute: () => rootRoute, -} as any); +} as any) // Populate the FileRoutesByPath interface -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { - preLoaderRoute: typeof IndexLazyImport; - parentRoute: typeof rootRoute; - }; - "/attributes/$attributeId": { - preLoaderRoute: typeof AttributesAttributeIdImport; - parentRoute: typeof rootRoute; - }; - "/attributes/": { - preLoaderRoute: typeof AttributesIndexImport; - parentRoute: typeof rootRoute; - }; + '/': { + preLoaderRoute: typeof IndexLazyImport + parentRoute: typeof rootRoute + } + '/attributes/$attributeId': { + preLoaderRoute: typeof AttributesAttributeIdImport + parentRoute: typeof rootRoute + } + '/attributes/': { + preLoaderRoute: typeof AttributesIndexImport + parentRoute: typeof rootRoute + } } } @@ -62,6 +62,6 @@ export const routeTree = rootRoute.addChildren([ IndexLazyRoute, AttributesAttributeIdRoute, AttributesIndexRoute, -]); +]) /* prettier-ignore-end */ diff --git a/frontend/src/routes/attributes.$attributeId.tsx b/frontend/src/routes/attributes.$attributeId.tsx index 334a2d0..1d0e5a7 100644 --- a/frontend/src/routes/attributes.$attributeId.tsx +++ b/frontend/src/routes/attributes.$attributeId.tsx @@ -3,14 +3,19 @@ import { AttributePage } from "@/pages/attribute"; import { attributeQueryOptions } from "@/pages/attribute/api"; import { createFileRoute } from "@tanstack/react-router"; import { Helmet } from "react-helmet-async"; +import { labelsQueryOptions } from "@/react-query"; import { z } from "zod"; export const Route = createFileRoute("/attributes/$attributeId")({ parseParams: (params) => ({ attributeId: z.string().parse(params.attributeId), }), - loader: async ({ context: { queryClient }, params }) => - queryClient.ensureQueryData(attributeQueryOptions(params.attributeId)), + loader: async ({ context: { queryClient }, params }) => { + const labels = await queryClient.ensureQueryData(labelsQueryOptions); + return queryClient.ensureQueryData( + attributeQueryOptions(params.attributeId, labels), + ); + }, component: () => ( <> diff --git a/frontend/src/routes/attributes.index.tsx b/frontend/src/routes/attributes.index.tsx index ca6b72e..471e134 100644 --- a/frontend/src/routes/attributes.index.tsx +++ b/frontend/src/routes/attributes.index.tsx @@ -3,6 +3,7 @@ import { Attributes, attributesQueryOptions } from "@/pages"; import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { Helmet } from "react-helmet-async"; +import { labelsQueryOptions } from "@/react-query"; export const Route = createFileRoute("/attributes/")({ validateSearch: z.object({ @@ -16,7 +17,9 @@ export const Route = createFileRoute("/attributes/")({ sortDir, }), loader: async ({ context: { queryClient }, deps }) => { - const options = attributesQueryOptions(deps); + const labels = await queryClient.ensureQueryData(labelsQueryOptions); + + const options = attributesQueryOptions(deps, labels); const data = queryClient.getQueryData(options.queryKey) ?? diff --git a/frontend/src/types/attributes.ts b/frontend/src/types/attributes.ts index fcd7bcc..69ecfd5 100644 --- a/frontend/src/types/attributes.ts +++ b/frontend/src/types/attributes.ts @@ -3,6 +3,7 @@ export type AttributeType = { name: string; createdAt: string; // ISO8601 string labelIds: string[]; + labels?: (string | undefined)[]; deleted: boolean; }; diff --git a/frontend/src/types/labels.ts b/frontend/src/types/labels.ts new file mode 100644 index 0000000..c83c929 --- /dev/null +++ b/frontend/src/types/labels.ts @@ -0,0 +1,15 @@ +export type LabelType = { + id: string; + name: string; +}; + +export type MetaType = { + offset: number; + limit: number; + hasNextPage: boolean; +}; + +export type LabelsQuery = { + data: LabelType[]; + meta: MetaType; +};