From 6452331ee7ca5da622179d811b474353e35a774e Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Thu, 27 Aug 2026 00:50:55 +0000 Subject: [PATCH 1/2] update tailwind to v4 --- bun.lockb | Bin 196936 -> 192739 bytes package.json | 5 +- postcss.config.js | 6 --- src/app.css | 130 +++++++++++++++++++-------------------------- src/index.test.ts | 7 --- tailwind.config.js | 64 ---------------------- vite.config.ts | 8 ++- 7 files changed, 63 insertions(+), 157 deletions(-) delete mode 100755 postcss.config.js delete mode 100755 src/index.test.ts delete mode 100755 tailwind.config.js diff --git a/bun.lockb b/bun.lockb index 508f4e554e89062f5461a65b8fb45cbd3bd00bbd..1975c7c2d2841d3a9a94de031c064daf3a29248d 100755 GIT binary patch delta 42018 zcmeIbcR*A}_dmRQ1!S$e0s;a7ELZ{Q*g?hKu%K9JOYcR&wpdV%6;~a5jV<=rdoO6z z*jqIAXzV4472nUfWecErzR&Odz2EnbY;rvJe&)=XGiT16nLB%RPmHc`?@sw?^<2xy zWHPmT#pGkYZluUNNptoa>x6kDK zjZ8K~9pRd+9wJLk$;gaJS7+2iK?RvCH)F6mF;gv*jYqBWxE}^icx7-q@C0xr_#hFl zS5W8}8jPDt0Na9>1NRe!!Xs1ClhI&3+*btt0gYIJ_cxQtY{8F!TZ0FQJf*Bq?g%+* zT%iq=*H)~=CDmS_O_N(|;?NM)=>c99yqU;ri(Do0PpCp|Jp#7`zbNtpB3~==*&-h+ z@>G%c7kPVd3)J@&xF)xT2q;Bf1ou>dz*CW57x@X1ZxeZXN@8MGs!S$_^$w7K0PF3+ zPk__?25@pp0r+>|N#HfXJA*rcyYqMxS#AYf)Bx}Yx>p_i6u2Y!T9HoyCj}|ueGhPI z$X|PqvksI7G$B!)m5#BQh_)3djZ#Ns#o#_E1ApM72JQkp9s^3{F^MS=>FR7*q1LvH zqhgGcFw&vgz%tgFHjp77MX3jeCuXT-ktrz&aR{PDz~QCbuXVi*x={_~|EUYAL!MkB zxI*U%%|E)r3>C=*C0qj6P*k5m4~b(Uq$Cbj%Yy0)E^Pu%0qqKoxXO(U&qx`PER$&( z2pv0BkDQ~C60mhIWkk7k(C*jm?2YVq-Ckob7Tn}g?j3Y)O0nP z?F>1J#fQMDJ5sg}1Wx9gbmsADxvl^#0aOPkyDy=D8mOmzRkn7c#ICxz3NNQ@sSf51h1wh6r7xVF-sy=AagJF%O)2*0Z+|yNS4`9Nq<- ztdK(HSRbLaJtA)xD%`KfJ&m~N(rP-NN3T3*@2|||QNg6n5l02k}z%PK)WH})6zY_&d z>?dVKDP2oxT1vx6zZ$+L|HBQ_|9HO?jgFDWLcl4g^&Nr#{}*?D zxiuOhVrUGdg+yBEq?JutxugYXo3^l$qb5u=`vN!(U?!FkOb1yo?knSdd9D!3Z@_6F zXM$6|CxTN%WrjyYghwQ*qtt0x;fR%pxXcXKtTWKH> zfs=u#k9E;s)&XTC-w2%QzZp**!ld0dLD)*p1FwOCY>|h6SI4~%xHGt=$lvA*S>U3` zw}LyOoPkqTJvCX#wzI*>gOk9iBiq3#a#n(q2XjasUe4{03rZ(#4Y%4wDM zeQb`bAJVdly!Ei9UYENpTK?XzN4smUO-B06*&J*C3#ZL>Ox3tHw(yzayko@kUiYk& zvnwC=PTb?^8<_R6u*rz9hFRl#Jnm%L?U_9OKuwQew^n_3O`TIQ#P(pjH&rh6T5vM9 z^B;xzPkOG{vZ7N!!A|X-(;02cHg&2su+5{TKC#O~wiR?+k!p4ReuEEZbGF7UTUu#H zl`28Ez7*VysdcvATU{z)H-9|H3#Jw=W!vB(|t?T(7t9H%XHFCnf`}cRe%W;jZZ5le+^vZho$itWNlVl4<%32Os z+H!IHh;v^q3`sj1t1;k(g{S~abL z=DKT>YI^ayozfKM0NO#kjMmx7k2h>BkN`qN1*lxzPxTbHO$-`?fk11U$Yh=dXc;m= z6$;eX0OhnE-hQg7KpO&uef$UJdiyE%18oh|gx%4~1p~^Un@s^pz2%kKw!m$B1Er|P ze=5T8+PvWfknnDF76xs&kaMj<6*5^zps~?E~ z5?K;SQRlbH{4gH_>S0iYcZW30N;jkf3NFdl+J@ErZKDDD0hIhBdC)`Gzb%kx2?=AH z{{l+mA))-hsREGFyn#wyDx=-h%uhv|3~EE{h|G%jMQ$aTtV;>>4A4#`&}zuZCDG_o z(Vt31i-6K78Qahf8IS_q7@bxsdbL#43CX{thS*ZkjisXRN<{;zl%_WyD8-Mly=O{A zEiFou?OQ6k7-;JfdheHt*1>vMQZ}(vbYrP#87m=knTXx6;uFMoSheubk!y_R01YjH zo-b9_73*J*60*5K2b4f>0>!$RYus8Jtfb*3(3L<-YB0g-8&g6y0jRnJ`U}uOB~W)P z>?P4DKufmvq*U1sIDsnZon=6KmuT$+&=3O@$=cITwHEsVnz7P6rZvXT$^;7p&3P%# zg!N)mH-5cPw!y8ZfhKKgO@C!Jz{ZAPLvMf8PsJpKd~hFV1BgnA2)P5xhSE_ciw7#@ z4lFUWKmaARk|oHPqx@7CfYM?jp?uCYS2zhdiQU%9{k2X>3UdIoy6D%j$v`Lv>KjQ* z^d~X$0jH(q&S{jQEP!SJv`$Vo(|zkGe4T~l!6BXL7vNl=q+N2IIlp#o1nNd8f?6l5 zxCNAwoxzIMpt{fpDZld>R1pD`Y@wx8UwszPCdEL+Ij{r5CU4jPdTE@{TpqW4oXP1E91R7pKZ*!N@WLE+;oCSgg zliJ2}PCyfYs!Qno1*qZd`cC_*jlZ%^agc&+3eb{`Jpo$MWv%O$rePV-lCqzG8csys zTt$z10;=T0jkbM%0r5izq)4i&eU<2E9#vl^iw24)^w)+>R48$8xa7+C#nlWmDF{-W z7zEe}U)17kWv~n%=cFUEU92J(3Vgui3+>;vp{)M4gI1pg{ufH zV>Aj)fR@xd1SpMyQD`U*iW&qLEN-e$0-(N1&BIcJsxML)8D$(S*+3~#n;JF05omLg z)tO4u_c>4sED7a@Tj34sNmi~4H^y~^3)+#0YDHw zxn^R+Wr=G&`tl2+A_b+qUMx}OjnYp0{STUon&w;!4CZ_q{DzYKq-cKLHuFD z&-p^2yav?NPZ(1eOsF(rgs0;`pa>xYl+HPZ0i_8e>6McWicLVt$8;y?rL-d|Fi1N% z5(&Vf+15ZU->CGgY`O@gO@*ZHALwC@J1Bu#gY``1h?W9?nus=F4Z=CcAfV|6dWE&; z98fY>k`*$q3IcTeteK%EjFnh4g_+!=DecC-U=lE>XboO zk)z8Y$SDpa)Rd2zAfRNIG&H=klw*MM?TU`2J%wr~KpI*}6pIJK>x(EVd64(3sZU2J z&}DR-;+O*li{#!)f=XRipx@M0*RT@~|y@>+Fce!HE56p&9P1sqL>icURCOSy|1D9lk10C9>s z-4Lj@0L2&<8*C-4$hbU{XO7|C}-u<|v>5Lq0HYSOq+y5h?{3pKes$MO2#F z0y+g~YsgA7iO(#G^FV!omeY5?Y+or~Q1@xD00cnB+%PGtW&$;y)0Dq10L4nJi!P3y zK2){v^zCJUs(AnP0qSdj@&`D|DL}D28Tn7~0w9?{6IdTF%`uHA>ZK8-=NLpiP%=Ru z99Un?4+Ev{>k@s%vUozKt_%AC$=6}y73L_Q#`Ga!Ivh}Y?Yil@4YcYg5W`{SXMDxV z1BH;_#~F*SFn;v3)CVPou}vA%PqhLlVoP^gE$mhkgW7n8 z%=TA`qEbU<+O(ej%Ba$O9RZY5uaT!zKLIW2zjr{%Tl(GD?UstlYH^hi&7yB1l=L1M zMTdyV_$0jxU~7P!i1J$oMUjLW9+0WJM+-w`BFYM`nhuomkc48mTKk(zpl*6P#VM3A zLRgd$>kET51t`^*P`I$UUxi~p$u1JrHq31(?Tahcj1|Ny^Cbqm0absLV0F@U#f_iP z1LFh_n~^*|!g6~ED5=+PHg9)On8%9&A`Nea)<8%lRn)%JTcKKlf&dhlPzinL#MJf< zP(Ps3xJjk1P{@B%rm#s6^SxotQ1k^#DO3Lxt(*lY0FV?d+J?>jRhI!$v`I4qn_+Li ziscgZM#7F%-TYScLkZ1q-TqUxT0|sg^BF=ROA=zpVD)bd$b(GcdxvkYX;f4RKuMtp z%1pJJ=KGriq}i^}rE8USvhkN~!VzKt(Do3NVhuB(rl09&paI%viMljq-Z@1`HM+C1 znE3vJrm z`dX(1g=L!Y7nJ%!q*1FLC^a<1xODM1OP_tSv~l{kG>=DV$5KrutIng8oK~_!i|Z*Y zGrm@cZ>GOqNP5?OwJMeV_07^2nO|#`>gIp5^xQW~t7QpKnk9Qnn!~?Yy6T&yFTPpo zJ6PBml-};JZv~7C}s9|JVh_+VHido6F zYPNBBmz2N@GJtv+D6r=4eFy#1{10`-BcwBIUEt!iII)z>brt}q!3FKX8{)9ou%VL%!|ysBs%a%N4x zipzlp0_8V|Sn&1Rr-vvZZjur-xLX#xv^PRx(%9!**-%?0)hlJEuBr6#37HFUW>ZDDJ_EW`b zg;xtCSphu^q=?N}#=GmWFNvO0i{ZgT<`U&{UxPO30o9N|9u& zp(Yc?(HI>Dl=8FWRJnH3+$N?pkM-}a-~|^Y#6kc{ zDqq8+0;d4o0HPspwn;27moH$+DiTD2rr}|E! zyoN65+F1nt6HXbVA>?hr+l%%7ORo7pRq%gUALDOmIk~2b*dyXKaGwTFK{!OjiPIS7 zfD<%S#J|N!WElRSdLu-6p4$`$#Cy56;_})m5+5as6PIfTEVph1P&StC!1M42NsSlp z^9jVKRIZ)8+*(6nH%XKtPM4F#d*Y;Rs)$b$apF`@E8;Um+>jGAQ^bjr9tKX(Y!L_7 z@W33gV6Ip|oWgsdcyGuFS|sA%;?&?0QO=MPv{b~G%CQBfb##pg5U0zv;yrOvv|hX? zPL~_#5ACxRg8w#)rG}hpZ3Rvtb5$%SPM6ojdqYnC{8hw>Q~g`ugx?YG?}6ig*?sy$ z;MI7+hho9+Vgb*!3szcdsB^D?lA5<-X=$8Re^ONztSmUyQ-YHjRp<|qlL`ym6Sow( z6*!-S!12G#Uc?;Q3!fu-Oyq{~G917BWSCZh|A;x%Hy zTD|~D2b?a~6Rth|qcD4S;EvSoAryQsiHMvo_lx($$&5qdJ#mshEZ!5RR!@rerE!u! zC6tjZyC<2`_=5~P2VOy&w5Dq?&7ga@BQqX?le*u*@xSb`c>jmUpAd+TAt&=* z1E-?5#I?=VTJu|?zeQ={l&Z>56aU}v2!1D69t~1wD}z(Fl;C6&{hdMjNSv1C;!DgGUflY;=fOHPjvr2(b3+*@JWv52;Kkx@`=vnKYOCv z+-TpCq=4K=?B;YCm4s8JR zW^rxsN4__J(*%Sv+qMAweE^JT3!on>B;XMNuI&H}U^(po%=QIvlYl|2ZhHXj`~XaD z4-NA;kG-AV8AsBlU08?Fiz|;{erw8zn>>M!-tJ@PSmyIGeid`c%ntAjB8^b0L z8_Vtx%VUi~z{arxV&mCkV)-nvH`oL=huHV*6|sq|eIKw%YzeW+>^-q5EI1TwDqBr# z8k6@0D`26-rn61Nv`oF^IvhfEapZm^BcvUswr@oKk zEmx20yKae_`IJ5#SFe5a@Y-nqTZ2=U&0BtL@`d0&)w;G=G5n7)n=+tFE-R{B1isx+ z@AR$rxVqJ2drpnraCDl9#e>$bZuYG|$82fLY)xR*?xBAMcC&GOa;RdbsQk9Fl*HLb zRGojFaHMG3c5Oq?E9pH7Lg*HlYG{gPv ztH^xV>Y!P}5)S;*_`Tz@nKR0kt^0?Qo1)WSw_faM-`?4!+uzyy?)Y5VGt5sz-!92< z*hZe%ZK!yDQ+3us6Yr;?Z5wXe^&)cplh90=a_jy*p<#zQWKF!%NaKTYqauiw2j%;xVJa#Y>wCMF``~F zc(0+}cJq5b>FA=_lU;4oT=}U10|u>7O->J5`l+hf_yu`eD*M~twhC)&# z8Ev;*INH@TaGLqm3QN6fcvVd?r?0{Azd0%1XQ+7lch5J6u0L=$w{G;R>tBw=tvs4z z_Q+zzvNxv=J@KD>^_OvJwtGgYoewp={eFBeP1)W%l5<@LUz>Y-pWo79D{Cz4E_{)M z^Gqn+Z>Tu(Ou%);)DG=)dOeSCduE*bs0+V$?O*i==SwTs{w7s>8^&@A`b1<+7r^>yY(B?XAX|?CJXK+}l6;HZa+7Pc!ef`EAXW zeeNB`U3nII*!-YJyGn}_o*jv4*|y_+`97z|ZD_ot`FzMwakt~i9=oPIJdYIZ;E^>R|W5wv7~ug6vOpSj@o>|G9Xt0jki+Sb&h z;{ER$Z#18Ax6hRR5%(SI#S|@XKIQMr?Obdc=oQP380!6bSN8{NYN+--Saag4+q#c_ z%TLC3@u@I=(@f3e1nW=liq6;it(?!zdMj=mKUes=LEULZK8swuX2jO(5K^?U|E{rf zIv6OXe^XETm?$^NhaG;g(`#BxpOD;ZUsMvbQql?Gtx1Lr0HxJb>4=T;= zxNgecUMrIxK7CSU^upiQjGg-E5BA9Y{o}NDO%*xGH{ZlsM$Sn-Z`bVkm!&VO&a!XJ z>y??)iyFrb^-iq)K55{-L#ee!_8Ms6c20AHC9kwRt#s-9pp5zXWj!9>FREfTE{l~L z8(059?bx{3iM5B-bz5rlXSapz;$P0a>aP_QQxu;tR6M-wjMabUEV!E1)@uG_C#bnA&E} z;}r{wN1>tO&n?XBbaec)=fywH-^+C?`swou|Bu~{d-zWJxykOa&+fH;_1nFF-G?<$F<#FGz6D>$?xDjw-FP%Nc}B0~z#9t=*jZK^RkQwwR~yF`Caing{rc)9$EW0aYOQQ*{$S(w z=j@dF79LZrme#Jc-{Z~V47*beN8V-w`op}SpK^*C<)vO|nd+0X?22}NmBLTMyg~<5 zelhshcfH>&aDU>`Vs@NczFnOu?m@Apc}1+(8kW=emgC8_nw4W0SR2mN(}w2lwr+7| zZr+%+!^)pJW3e;*(YBij$H%(=`YXr`f(+(6W2&aX*ZIcy&n9 zrVhdT?>rsnvex9d{UcU!0LZV6)?jJc(=he>p3%^TCJ5}fO%F~`> zf-c?t(|Nw%nxcL$W-V(GzcD8%v*P`A`)}p1JW(_K_>sR92HrbsXkOabX1@(^TE8|q zC;Gwrg#2#fmIg2Wb?VkBYuKf+?IV5qsD?jqj=b{P_J;S1k>AhyX~?|sN0z?`>Yf^M zqTK7428%Nrv%O@V&tUnPx~JFe9JFrPv6I`*E^aoWe4i0BU$?lNcsq3Bm0-UeS4M6A zJR+#F`k>mS)AKDo)*fhDbMKigX5%J&I8o2C5rvzyy*OuR-tixQUpS-h)rhyJ%lxqY z$7h=dmucab>^5`50r^nniQZNry(>@by4Kc2Q+4gJWfcx>%v;;M@$~vn(!E=a>1K5+ zC-}TDS%mGyc|*moTn@do%sxM3SJL81ua7+o^WLcH>2P~aP^ZUzD`!`=8FC@dPiB_U z%JlNC9N&SfcR9v)SB&kSxoYig`#JNQH9AJG)kwR53-q3d^fBo^qvn#wPwupAea5t1 z#Pp`;yB*zYJ2j-zs@1zHI(#U5yQcZ@Uz*$haqa2JCUv!Mul=&i&EL5hH_+_Vm9cXj zz9>$Z87RJJXz=-*pHEJj;##Hmh|QJ051Afx==iVm8=XCO*gNJzQ3Vax+-%I0;h7VE zZnDSzXoH?vn#GeEEEzs3x2x3STTKi#U7!E^ z(q*TP=6Aaj-)LaF zW73QM^V@*ShKiHMmtXmy-?_C7CS=Up+9JTw(xCsN8_+tx&Pf?!4_R6^uxmexmeJ9#ZYhWE%kqoaE^}Y(5!D!$gfS8 zJ(##j!}U@44WBjI@dw{YLE$shFS~@b9C%`S%~cU)JhU%cIP6&7%JE=>v>GSFH@va) z=M@Xv3+de#8a|V-^Me0DdW-1!fU4`&rM&)HHr#_{@_e=chv8#ryq;b+&Xum%hec*xIb&leR&4f9Z$_K)9b*|@-F{lkOj|9Cn7W@cf<4|jK0 zFI&s!@?iDqXxn*RB8tX;{Cup2 zf8v^XB+D&KcjLM|{e7Pv+At>*wm4aCZsmzL04hO*j$fsJ!aQ@k$0Yd$Ih&R$uONTB zeLPZXmiWSc`@v+nmM<_h7k`7~$@b1!@*Z;eWwvCn+{<)IL%h?p{Z4^=yc~ar z*^(MkNI$=k&PHX+7s+=s=N!3(oNwiKlB&a650g8YRtUt4McbF8%75qhYSIriS(OVKVa7`cS#Ze3D z{{sQz-y)@Qa^FJ`xo8{!YL8wJe+Hrty;Vwb^deeM0`QptfQ+Gro}&VJZIUQQuD>7} zP4AXUuBVsac>jw#Z~mQX6H$=9g2eyyufNm3rO6vL696?pf0rRv#OMHpJm5e$uE|h( z$|p7SMh?9JKp%RL#{c}^v7oo@Xzb}3xavS$IDyKE4bvJM85hA@h7GR9vF@rLi^Z0b9CEw=HUJll*SD^$kZ-pkzv(!ViIZ!?*JOhFW3 zXF;bxKZ6c{4ua_QuC1UgplzU^KpQ}tL7PDHL6M*+5WQSTQQ1Y#PQI7BRHTKTrVmY* zf$Z~pc}Goa6gLM2f+)6HfC50RK=igG#g-#FP#shQR1;JSR0l*cRu9wwL~mw#fILB7 zppIyQo+R6YYJw=v>VVuqZy^5`^p3{*Ph9*3dJcLGqS$)@dIWk5dJ6gj^aS)0L^1a_ zh~f-cl>XWd{}R>~aQZ6`n?V~u8$nKJ>=lf833>s#1iA>KH`UIAex@;J=76Sy#(^e+w4m{z8K6m^e9%VpY9(kD=ts~J&|=U6(0tHb5KW9( zpaRfz&@>Ruv?(Cki_mnZJfq3QA0t3RLFu5upfpe>CC;<^=RFXU(i+YUMk+66ie+DGHR9~Zkpdq4+4he3xxM?fb) zg`l0F1E6D|k5K#p^bYhM#9^*{0jFg13H&oij_V?D{w+Zmpahi#S%Ip8tU01M0!th8HnD$`~kEA6bGU=b}8qq1JP^a3qi9%3`BX1 z@)o_0Pld~EW)X-| zcqYm_0q+9p0CK~9Lpi%#ByXu{iSpK;_ZQ(~k zTcF=ScR+VRw?Q{Sl!?oOhCnVCGyyaUln)vX`W`eIlufZS1{Whh6G4+fV?mQZd7vB+ zwlK1BprN4gAdI$b7-%M_05lCWA2bWZKw8iY&|J_Q&^*vW&?3-u&}`5GkkBz`yNsJm zSUUog1sV=Y1&ss^1|@=qfYLxT)T2Pzpj=Q6C<&Af8VX7V4FhF>Qb5)q3(!|RLA@Cb zf-C-4JW+{U5%wN~i_a=kHszh%CHOXOX(hb{z74b$M2TuCh&DQunEHbb;hL5rX^o(` zq4=RVLOAkygW@ZIt&?*bOh^R_=eUk+4aa4fQ`-B5pgLu^i}+IMa~W>GVi2lPhzw#! zO}N@76d3HK3D@6*q**spt~DfQnsWV=1khs+Z8CW^&}?H83S_0d87DWPvh7Y~xwl-! zKwP!}SwLHBR#2X^Hl=WH%huttS6^KA28DonfoQu&E1wyptAJBaEWkU1I)QqE?1A+F z?+U65BAIUBj^MO43}&hdoVA7^vWR9ZRUum&(W3Q&3IqU)8QWuW49Dp1}c!5=`>@N#gf zMAwwgTB=%;i3Aer}#X-!YYn>nvBWK`PGH&JjH)4O3l~44<$tco)a(_ILA=)ni%} z7o?z2u!g_~b_eqE%50PcXUQs8;Y{WASYSP_Dyv_GbMT=Zl@emnD~50XWzU1T(Bt9e z?dIVnYl{-Hq}}r5^;aFu%A&+aus)cLsKUkA4T1oYYi{8mfBp7)(vpi1@PePcFnQUR zDrj>U3%B8{9Xvc}FGPZe-fyRl8PwFid%o7 zZBU!n`l`NcFX?U#0g8&(@m;1njlXw5FVLO6f&ikdd{xM$K#u%BxMu^?T4(a6P!kQ^ z{Lu#uYXJeD0tjF}<+@myTwS;(W}_hBfq4D_C6*`&3TiiLxTQ&~P~wTIJJ@8>eHsFo zj=9RtgV?L?3!CU|xXTVgK>jzoL2{Kb8EhcezOrn3HOBciNFq_8grclW)$( z(HCu>u|wOb>|k$Q+hbX<8)w;=w?q21#v5az+jz{neo(JP`aRuxRq#sH#CuEhB?p+Z zJMRJXL;4v=ZIh=p;wR6|*GoNOY4)(<6O;FY6$|ZQg^CsQ(OFTO?MJzf^m~(Q+Qbb= zZ&7=`UV-$Bl!;+Wzg#ZZ8K^IbV-+2sx*n_TfPwdC!OmzQ*?}{&lYYy3>zAjm%T+sB z%S7%?{zR0eu&JoyBmMZbS?2ku#4~T;R!KnmdF;)#Oa?T3lxLwYImK?Gs-5(E+7qXK zp1-|@>JPm@1*ZBA_PDe9V0O|Ea{JX@=kNLPmo|F2BsK^FKGH9E_q?@#$@sUUs_6wb zR261Oi?YGd>G_3G`jQiD9jdmKeiXd&-E$rdKArlc7my>`v4H00r_ZZ>GrxUBeTnoV zf$a;n-neyc%rj2jja78yobfJ}yQ4IXtl3aU&JFKZtp=pH4NWzn%pk9_B1bL^<%66! zTg6c9_b4S4vQbW)m&F(-A;%9dIM}!B0jC9=Jc}K7;(B3(9Gs;Qvc(&E3C{43^dsBB zbDuvCSbVOCEcK$fE;D0`AW-TU2?C{!kx+7qJtN(vjgcTwfjL*_$EXRIUG*A52L}|U z1-0%l;=JC5&MXB2KGLtNPakC;I&$!zb@T$Ijh@iN2DX=UOTSj`Ftn@u!SGAD`l@Z% zE3)AMD_;Y0rHz=N=ttH90zT4@hI?&up1F}*{*jX-l93YVK)@#&tC!ePpy7}SpmsPIGdD%%nSUzvg$!cjy zJIeC{{Jh~;d0V`&=UNM<&tU~Mx%!HwuKboi_eXZr_?GOpJMSheh`q*zSBl@Zd5qW^ z2AJ34g_kJ9Ntv*)m+_L#VKZxS^=sTfJu701bux!q`8ju1;eyN~Xu8RHVHFG*z!P}NTQHTCXGPj1&Hv`^6sOkwA! z$tCPbZ3O-{=7^Sk_Cp^Qlw7lp#}}=={$iM=uBET9JcTR?Rqd{#suESx3J#~cG>FO3 zFTQ-QyyW}Xr8>GvT*kw539C^T_d*|8YF(^YLjUcSKow2eYen_nyvXXH)f+9kOB@dm z&q?e}U97hJ(07)cDSA;*jJF?e)-yDv|N`cwU3;VJZcNp>*(TF7>*HbkjKIlj9cTJ^@m7{c-xlauq#vU9|4 zF}XKt)nwsPy~lwwt3~@dkU+md}rkfv{a51ZYNQ@BGuxv~l}2 z5Wr^J4IL2F$o<$8XcLtphdKJdh2w*SQRp3fvGah}g?B_hz=bnWLe4FmJiL-McPX1g z5b>+2tSn3NfpuPN2{g!?v)w*iXFIRP;PY~}vTlRx5r>vlq59!1|MCY1xW|jOn8=-<&ue1CT3vYoc_1J1Z zm=Mlx`r$5-{Y`h?tiC_)#;_oN&dVnZzQz}UxwaVY#n^3X1W+8ICF~3C?Dq8%){-^OeU^U6`%+Wy zw^OV$!o@B$L>R9p6`X9#kFMp(H_t!j3-JW)^BU{bc4m(oV*%{aNBAXY!%1OfN^Z9M=tV;m1D>d?Y?fcmx68Khp^POABo;AU!RA)_^!V1HHwlw4S0-f2irvDJt>)Any z`2QxjZRLB|faa)tj16fH1KP1Q#Ds{j?~Jjd$E4ra=M?n5oAx)HC!UcQM+Ixs0x6%5 z2`_mS{6!K3;)Q;~JeU|YW7D4Ng;?cj8epEt?y=7lJMPRnp!8v-A@>Q?4GJ3_2<@NP z0!&Fe(?P=2J!91_@Q1hYk6;q%MSP2MR+s*e$E-}LM8zCiqCGeEf>>wvr41%?VoR<{ zqkqndiO7!hOdEZz#ZSFl{U0dhNie{MsT{d#Cf+!|!rxXYt{WS`5pB5oj{l&Y*TKfO z;{5QJM)tRYmSe1P$8UDgs5QH<=W<~t%SG|$Y$|N@@jxP>i5R;7x#|2{&+3St!*u(4 z^0~lnqN?3OOa~>Z?r$*fdXru@9Z(gUM@r)wrV8S`IFkt0CW!MC_K(;pxI(1Hu9*7ktTG#i7x9%~Cpz}3Kk_fO1V#yEL_1n7l zyjd9~I5i~mAI7rvsO$3q5_G_`^v%*04gUE20TK<}ym7`Rs}v_}xTT+l=j-YZzHH@P zm{kw1e)U?Avx5e8Mq*rYCce__nLBKQa@9MfJOCxMJkRRFRpYkAG5ao@4U1@m$MLKl zoU>1$xQ@=7Q*&Qu?aH@k0H=_&j?Ro1rs;v*6QgR$I${dZz>t}o7Sn*B|&dOYB znB5+P&4xu!tVR6sOH~s@}CWcuxe92x-OP<+4ob6!J4AFd|fZ< z#H(c1Gz6p7EQN)HaGt)mQiR>iEM;z>*MRjwkVHnq0feksst^-nzieIj(Z(tnCGaz~ z-!7HygAU=O$kNOoPk1u^q{vqOl$Gy|cq;9T$b*+tvKA2Vsey+j%EC)mkBonAUJDPi zSkZZb($0v468?Xv~BVCx^P5f!JebI)a|mMWG$=Q2a}cG*;+UY zSZNzmp@iR&TCmhUXp-Ni1y3QEPGdJ7ajly);BvxA_MM zi?SZrQF_Yw<2G@RZONvFa;A1qaq>z#%!3aKR#{lL{hN0VFI>bngu;8J9kdDBO7$yW z)t7xnRr-63b~w)xq%V#r9U-YlEV>Lm6;6_pUB0CLfVHYp8 zz&p)WI7PE$?)}ia(w=(+ZOvHMerT?==Mh05j*aQZ4KkMw_}SwAoV6puJy;_w1LwhlZl;Gf(a zNoNr#3C?5lQJ0IzW82V*=Idu8O5|D!^b?Q0*Jf?J^;BPP1%jr5#nO1nG-bqDYCNR?4KNw;zphLj-Cs zD6v9`)%p7kKAg?jDrlokMR9GMsLe|>P9w{etW(PCP7z{3XPs!AzNC0xq0Up-Vtu`& zxY_*SCm&g;qcL*bf1)lvE;A0^mQ9#{xwlD3g#&yY{s`jhv%?DWoxZmlxg#s%g#RTx z8hW^S(HCPf@#r$a)8p&k05zB^uYB%VeA^;9C?6&GhCO#^Mp!44w%PceLL(I|6^k?`-D|un$UmpAvwM|1O zn>N3qFRCvVjhwLW{{0>Aa`YwbP(pde*7bakRlDZx(v>h(C^n@Y5nLnTYZKk+wTF+I zEsEf_+Dk8yZ-`no@Q1LEt0`K%v0K2asdHFrB3EL?)1%OKxR&=I>TMq zmy0*4G25G{x${wMd@N_j9By#ttno>%N*UyQb}*K6CP=aU*Xx{j8TQjjZ1R-1I4@;t zN=7EMx@M>o)sdMg>8$B3t{+=}n^Ul;i`>oa>=rkhQzXVEMy4c3$Hg#JG-tQH!yWE; zRd!_xXFuyGXS%)VM{Zx4{+Y#Wba&-Vb>-I_x6GKK0frLEDN*Q!L6wy3xF|Kv6t{*> z!-qQ!^yUBbt?oz}Nap6GB;3cse}in(gadvN_l2bhSDnBkC)qL&7tX zygXgO;;61hHv~qS!_$*W)g7K3m7Wq8<@&X9JW^$4Q_HR~$yue?rY}#*%1DHOMjr!$ z0q7UZBMnIzgCoDHf;ZrsjR+EAGo`lz5!e5qA!i#_4PHj$&1aJ) z;h7PUS)+MzDOaIqqFLyhZlsGB(9nNHhu}Yb*K|6H7wB{}En$bDD0)_^3g9{ojZ0`S z9wVIsWB42OjiW$U-x$`__b<`DUa+M7V(fjRwowf08aG09wS7ypElxdt2y~SVL0#Pv zQ=2B8H1~8>4KS*S|0t`dlaj+z}s^*%N8J8#((}Z@- zj7?XEM`Z+1W!FmSqL@&VEqTY;w-JhrOVlui*C#gN${%-;9oVbkD2q>5{JEoRcw%a7 zxSKZ%%;X&G1RKRl(4nvH!8Tpxs#{C8Q8!Rd47!EF+ESsR;esK$evlzF5o$UZby+f( zy6=jOAv*vO?-Cngx`v*DPFEq z)56FaLop$7f2&xhq?wVF@h}y)#JKb;Dg-P5ui~p6krpCpWQ-LU z1#j_Q7!~R)Xk=u8;k+nb->^(q-Os4H!E`HL+n`8S*~h4|ehL?_t1r=2^)N^T5I49( z#-Wu4xdKVTogQjk=`XD&u}vp9hw^j}d15Sh99N6I9L0HVum6RcEH{tr65V!i_lzz~ JK2ARS{{e+Q^b!C7 delta 44589 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 diff --git a/package.json b/package.json index 3461051..fc8962b 100755 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "@playwright/test": "1.49.1", "@sveltejs/kit": "^2.15.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.0.0", "@types/eslint": "9.6.1", "@types/pg": "^8.11.10", - "autoprefixer": "^10.4.20", "bits-ui": "^1.4.7", "clsx": "^2.1.1", "cmdk-sv": "^0.0.18", @@ -34,14 +34,13 @@ "eslint-plugin-svelte": "^2.46.1", "globals": "15.14.0", "lucide-svelte": "^0.473.0", - "postcss": "^8.4.49", "prettier": "3.4.2", "prettier-plugin-svelte": "3.3.2", "svelte": "^5.16.5", "svelte-check": "4.1.1", "tailwind-merge": "^2.6.0", "tailwind-variants": "^0.3.1", - "tailwindcss": "^3.4.17", + "tailwindcss": "^4.0.0", "typescript": "5.7.2", "typescript-eslint": "8.19.1", "vite": "^6.0.7", diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100755 index 2e7af2b..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/src/app.css b/src/app.css index 1460463..af36e8e 100755 --- a/src/app.css +++ b/src/app.css @@ -1,78 +1,56 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - - --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%; - - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - - --destructive: 0 72.2% 50.6%; - --destructive-foreground: 210 40% 98%; - - --ring: 222.2 84% 4.9%; - - --radius: 0.5rem; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - - --ring: 212.7 26.8% 83.9%; - } +@import 'tailwindcss'; + +@theme { + --color-bg: #f6f4f0; + --color-surface: #edeae4; + --color-surface-hover: #e4e0d8; + --color-border: #d6d2ca; + --color-border-light: #e8e5de; + --color-text: #2c2c2c; + --color-text-muted: #6b6b6b; + --color-text-dim: #999999; + --color-text-ghost: #c5c0b8; + --color-accent: #3d6b8e; + --color-accent-hover: #4d7fa6; + --color-danger: #b44; + + --font-heading: 'DM Serif Display', Georgia, serif; + --font-mono: 'IBM Plex Mono', 'Courier New', monospace; + --font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif; } - + @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + scroll-behavior: smooth; + } + + section[id] { + scroll-margin-top: 88px; + } + + body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + ::selection { + background: color-mix(in srgb, var(--color-accent) 20%, transparent); + } + + a { + color: inherit; + text-decoration: none; + } +} + diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100755 index e07cbbd..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100755 index fa1cb36..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,64 +0,0 @@ -import { fontFamily } from "tailwindcss/defaultTheme"; - -/** @type {import('tailwindcss').Config} */ -const config = { - darkMode: ["class"], - content: ["./src/**/*.{html,js,svelte,ts}"], - safelist: ["dark"], - 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)" - }, - fontFamily: { - sans: [...fontFamily.sans] - } - } - }, -}; - -export default config; diff --git a/vite.config.ts b/vite.config.ts index 37b6a84..896d46e 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,15 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [tailwindcss(), sveltekit()], test: { include: ['src/**/*.{test,spec}.{js,ts}'] + }, + server: { + host: '127.0.0.1', + allowedHosts: ['dev.stanrunge.dev'], + hmr: { host: 'dev.stanrunge.dev', protocol: 'wss', clientPort: 443 } } }); -- 2.49.1 From 97ff9852cd7c75aad0734746357af35327276988 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 29 Aug 2026 11:38:16 +0000 Subject: [PATCH 2/2] update homepage draft and other pages --- src/lib/db/schema.ts | 12 + src/routes/+layout.svelte | 154 ++---- src/routes/+page.svelte | 105 ++++- src/routes/blog/+page.server.ts | 26 + src/routes/blog/+page.svelte | 28 ++ src/routes/blog/{slug}/+page.server.ts | 22 + src/routes/blog/{slug}/+page.svelte | 2 + src/routes/projects/animaldex/+layout.svelte | 15 + src/routes/projects/animaldex/+page.svelte | 356 ++++++++++++++ src/routes/projects/animaldex/+page.ts | 1 + .../projects/animaldex/admin/+page.server.ts | 53 +++ .../projects/animaldex/admin/+page.svelte | 170 +++++++ .../projects/animaldex/admin/login/+server.ts | 25 + .../projects/animaldex/api/animals/+server.ts | 73 +++ .../api/animals/[id]/moderate/+server.ts | 51 ++ .../api/animals/[id]/sightings/+server.ts | 78 +++ .../[id]/sightings/[id]/moderate/+server.ts | 36 ++ .../animaldex/api/detect-breed/+server.ts | 18 + .../projects/animaldex/api/map/+server.ts | 127 +++++ .../projects/animaldex/api/upload/+server.ts | 22 + src/routes/projects/animaldex/layout.css | 1 + .../projects/animaldex/register/+page.svelte | 444 ++++++++++++++++++ .../projects/animaldex/register/+page.ts | 1 + .../projects/catch-em-all/+layout.server.ts | 5 + .../projects/catch-em-all/+layout.svelte | 30 ++ .../projects/catch-em-all/+page.server.ts | 110 +++++ src/routes/projects/catch-em-all/+page.svelte | 330 +++++++++++++ src/routes/projects/catch-em-all/layout.css | 1 + .../{ => projects}/progress/+page.server.ts | 0 .../{ => projects}/progress/+page.svelte | 0 .../progress/[id]/+page.server.ts | 0 .../{ => projects}/progress/[id]/+page.svelte | 0 .../progress/create/+page.server.ts | 0 .../progress/create/+page.svelte | 0 .../progress/topics/+page.server.ts | 0 .../progress/topics/+page.svelte | 0 .../progress/topics/[id]/+page.server.ts | 0 .../progress/topics/[id]/+page.svelte | 0 38 files changed, 2185 insertions(+), 111 deletions(-) create mode 100644 src/routes/blog/+page.server.ts create mode 100644 src/routes/blog/+page.svelte create mode 100644 src/routes/blog/{slug}/+page.server.ts create mode 100644 src/routes/blog/{slug}/+page.svelte create mode 100644 src/routes/projects/animaldex/+layout.svelte create mode 100644 src/routes/projects/animaldex/+page.svelte create mode 100644 src/routes/projects/animaldex/+page.ts create mode 100644 src/routes/projects/animaldex/admin/+page.server.ts create mode 100644 src/routes/projects/animaldex/admin/+page.svelte create mode 100644 src/routes/projects/animaldex/admin/login/+server.ts create mode 100644 src/routes/projects/animaldex/api/animals/+server.ts create mode 100644 src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts create mode 100644 src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts create mode 100644 src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts create mode 100644 src/routes/projects/animaldex/api/detect-breed/+server.ts create mode 100644 src/routes/projects/animaldex/api/map/+server.ts create mode 100644 src/routes/projects/animaldex/api/upload/+server.ts create mode 100644 src/routes/projects/animaldex/layout.css create mode 100644 src/routes/projects/animaldex/register/+page.svelte create mode 100644 src/routes/projects/animaldex/register/+page.ts create mode 100644 src/routes/projects/catch-em-all/+layout.server.ts create mode 100644 src/routes/projects/catch-em-all/+layout.svelte create mode 100644 src/routes/projects/catch-em-all/+page.server.ts create mode 100644 src/routes/projects/catch-em-all/+page.svelte create mode 100644 src/routes/projects/catch-em-all/layout.css rename src/routes/{ => projects}/progress/+page.server.ts (100%) rename src/routes/{ => projects}/progress/+page.svelte (100%) rename src/routes/{ => projects}/progress/[id]/+page.server.ts (100%) rename src/routes/{ => projects}/progress/[id]/+page.svelte (100%) rename src/routes/{ => projects}/progress/create/+page.server.ts (100%) rename src/routes/{ => projects}/progress/create/+page.svelte (100%) rename src/routes/{ => projects}/progress/topics/+page.server.ts (100%) rename src/routes/{ => projects}/progress/topics/+page.svelte (100%) rename src/routes/{ => projects}/progress/topics/[id]/+page.server.ts (100%) rename src/routes/{ => projects}/progress/topics/[id]/+page.svelte (100%) diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 10445bc..f5a902f 100755 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -71,3 +71,15 @@ export const publications = personalWebsiteSchema.table('publications', { createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow() }); + +// Legacy ^ +// New + +export const blogArticles = personalWebsiteSchema.table('blog_articles', { + id: serial('id').primaryKey(), + title: varchar('title', { length: 256 }), + slug: varchar('slug', { length: 256 }), + content: varchar('content', { length: 8192 }), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow() +}) \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2a56bb2..6b1e466 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,108 +1,58 @@ -

- - - -
- - {@render children?.()} -
- - {#if !data.editable} -
- -
- {/if} -
- -{#if editModalVisible} -
(editModalVisible = false)} - > -
e.stopPropagation()} - action="/?/authenticate" - method="post" - > -

WHO ARE YOU?!?!?!??!

- - -
+
+ + osu + + + steam + + + email + + + twitch + + + spotify + + + signal + + + bluesky + +
+ discord
-{/if} + + whatsapp + + + threads + + + github + + + youtube + + + linkedin + + + instagram + + + facebook + + + x + +
+ +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 42db257..6d906a4 100755 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,10 +1,97 @@ -

Stan Runge

-Moon - -
-
Vash Software
- -
Hogeschool Inholland
- -
Junior
+
+ Blog
+ +
+
+

experience

+
+

2026 - ???

+

???

+
+
+

2021 - 2026

+
+

BSc. Computer Science (Software Engineering)

+

The Hague University of Applied Sciences

+
+
+
+

2026

+

internship at 3webapps

+
+
+

2023

+

internship at h5mag

+
+
+

2021 - 2023

+

junior IT specialist at Finance Plus

+
+
+

2020 - current

+

300+ IT problems solved in Netherlands

+
+
+ +
+

Stan Runge

+ stan runge +

+ me preparing for my presentation about quantum vision transformers +

+
+ +
+
+

projects

+
+ +
+ +

vash esports

+

automated osu! matchmaking/tournament platform

+
+
+

eudi-auth for 3webapps

+

+ plugin for WooCommerce/Magento2 that adds authentication with the European Digital + Identity (EUDI) wallet +

+
+
+

realtime collaboration for h5mag

+

+ socket.io server for h5mag (magazine web editor) that broadcasts user actions to other + editors, similar to writing a Google Doc together +

+
+
+

infra

+

server, project deployments and dev config as code with setup script

+
+ +

animaldex

+

world map with pets & other animals around the neighborhood 😼

+
+ +

catch em all

+

table of all 1025 pokemon across 38 games with checkboxes for catching them all

+
+ +

spotify osu! map finder

+

small tool for finding osu! maps using a spotify url of a song or playlist

+
+ +

beerio kart

+
Go
+

webserver in go with a double-elim bracket for a one-time mario kart tournament

+
+ +

+ a lot more planned (soon™)

+
+
+
+ diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts new file mode 100644 index 0000000..ba87e87 --- /dev/null +++ b/src/routes/blog/+page.server.ts @@ -0,0 +1,26 @@ +import { db } from "$lib/db" +import { blogArticles } from "$lib/db/schema"; +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types.js"; + +export const load: PageServerLoad = async () => { + const articles = await db.select().from(blogArticles) + + return { + blogArticles: articles + } +} + +export const actions = { + default: async ({ request }): Promise => { + const formData = await request.formData(); + + const blogArticle = await db.insert(blogArticles).values({ + title: formData.get('title')?.toString(), + slug: formData.get('slug')?.toString(), + content: formData.get('content')?.toString() + }).returning(); + + return redirect(303, `/blog/${blogArticle[0].slug}`); + } +} diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte new file mode 100644 index 0000000..e148cb7 --- /dev/null +++ b/src/routes/blog/+page.svelte @@ -0,0 +1,28 @@ + + +
+

Blog (Stan's yapping corner)

+ +
+ + +{#each data.blogArticles as article} +
+ {article.title} +

{article.createdAt?.toDateString()}

+
+{/each} + +{#if createNewArticlePopup} +
+
+

Create New Article

+
+
+{/if} diff --git a/src/routes/blog/{slug}/+page.server.ts b/src/routes/blog/{slug}/+page.server.ts new file mode 100644 index 0000000..0688a34 --- /dev/null +++ b/src/routes/blog/{slug}/+page.server.ts @@ -0,0 +1,22 @@ +import { db } from "$lib/db" +import { blogArticles } from "$lib/db/schema"; +import { eq } from "drizzle-orm"; + +import type { PageServerLoad } from "./$types.js"; +import { fail } from "@sveltejs/kit"; + +export const load: PageServerLoad = async ({params }) => { + const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug)) + + if (article.length === 0) { + return fail(404, { message: "Article not found" }); + } + + return { + blogArticle: article[0] + } +} + +export const actions = { + +} diff --git a/src/routes/blog/{slug}/+page.svelte b/src/routes/blog/{slug}/+page.svelte new file mode 100644 index 0000000..d3efcba --- /dev/null +++ b/src/routes/blog/{slug}/+page.svelte @@ -0,0 +1,2 @@ + +l \ No newline at end of file diff --git a/src/routes/projects/animaldex/+layout.svelte b/src/routes/projects/animaldex/+layout.svelte new file mode 100644 index 0000000..1e47fdb --- /dev/null +++ b/src/routes/projects/animaldex/+layout.svelte @@ -0,0 +1,15 @@ + + + + + + + + + + +{@render children()} diff --git a/src/routes/projects/animaldex/+page.svelte b/src/routes/projects/animaldex/+page.svelte new file mode 100644 index 0000000..14833f6 --- /dev/null +++ b/src/routes/projects/animaldex/+page.svelte @@ -0,0 +1,356 @@ + + + + Animaldex + + + +
+
+ +
+ + + Log a sighting + + +
+ + {#if showFilters} +
+
+

Filter sightings

+ +
+
+ + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ {/if} + + {#if selected} +
+ {#if selected.trail[0]?.photoUrl} +
+ + +
+ {/if} +
+
+
+

+ {selected.animalName ?? selected.species} +

+

+ {selected.species}{selected.breed ? ` · ${selected.breed}` : ''} +

+
+ {selected.sightingCount} + {selected.sightingCount === 1 ? 'sighting' : 'sightings'} +
+
+ {#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)} +
+ {new Date(s.seenAt).toLocaleDateString('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric' + })} + {#if s.reporterName}by {s.reporterName}{/if} +
+ {/each} +
+ + I saw this animal too! + +
+
+ {/if} +
diff --git a/src/routes/projects/animaldex/+page.ts b/src/routes/projects/animaldex/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/src/routes/projects/animaldex/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/projects/animaldex/admin/+page.server.ts b/src/routes/projects/animaldex/admin/+page.server.ts new file mode 100644 index 0000000..8fc901c --- /dev/null +++ b/src/routes/projects/animaldex/admin/+page.server.ts @@ -0,0 +1,53 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { animals, sightings } from '$lib/server/db/schema'; +import { isAdmin } from '$lib/server/auth'; +import { and, eq, isNull, sql } from 'drizzle-orm'; + +export const load: PageServerLoad = async (event) => { + const admin = isAdmin(event); + if (!admin) return { admin: false, queue: [] }; + + const pending = await db + .select({ + animalId: animals.id, + species: animals.species, + breed: animals.breed, + animalName: animals.animalName, + description: animals.description, + aiBreedSuggestion: animals.aiBreedSuggestion, + aiBreedConfidence: animals.aiBreedConfidence, + submittedAt: animals.submittedAt, + acceptedAt: animals.acceptedAt, + deniedAt: animals.deniedAt, + sightingId: sightings.id, + sightingLat: sightings.lat, + sightingLng: sightings.lng, + seenAt: sightings.seenAt, + reporterName: sightings.reporterName, + sightingAcceptedAt: sightings.acceptedAt, + sightingDeniedAt: sightings.deniedAt, + photoUrl: sql`( + SELECT url FROM photos + WHERE sighting_id = ${sightings.id} + ORDER BY sort_order ASC + LIMIT 1 + )` + }) + .from(animals) + .innerJoin( + sightings, + and( + eq(sightings.animalId, animals.id), + sql`${sightings.id} = ( + SELECT id FROM sightings + WHERE animal_id = ${animals.id} + ORDER BY submitted_at ASC + LIMIT 1 + )` + ) + ) + .orderBy(animals.submittedAt); + + return { admin: true, queue: pending }; +}; diff --git a/src/routes/projects/animaldex/admin/+page.svelte b/src/routes/projects/animaldex/admin/+page.svelte new file mode 100644 index 0000000..ca73304 --- /dev/null +++ b/src/routes/projects/animaldex/admin/+page.svelte @@ -0,0 +1,170 @@ + + +Animaldex - Admin + +{#if !data.admin} +
+
+

Admin login

+ e.key === 'Enter' && login()} + class="mb-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400" + /> + {#if loginError} +

{loginError}

+ {/if} + +
+
+{:else} +
+
+
+ +

Animaldex admin

+
+ + {queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending + +
+ +
+ {#if queue.length === 0} +
+ No submissions yet +
+ {/if} + + {#each queue as item (item.animalId)} + {@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'} +
+
+
+ {#if item.photoUrl} + + {:else} +
🐾
+ {/if} +
+
+
+
+

{item.animalName ?? item.species}

+

+ {item.species}{item.breed ? ` · ${item.breed}` : ''} +

+
+ + {status} + +
+ {#if item.aiBreedSuggestion} +

+ 🤖 AI: {item.aiBreedSuggestion} ({Math.round( + (item.aiBreedConfidence ?? 0) * 100 + )}%) +

+ {/if} +

+ {new Date(item.submittedAt).toLocaleDateString('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + {#if item.reporterName}· by {item.reporterName}{/if} +

+

+ 📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)} +

+
+
+
+ + +
+
+ {/each} +
+
+{/if} diff --git a/src/routes/projects/animaldex/admin/login/+server.ts b/src/routes/projects/animaldex/admin/login/+server.ts new file mode 100644 index 0000000..e2cd02c --- /dev/null +++ b/src/routes/projects/animaldex/admin/login/+server.ts @@ -0,0 +1,25 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { z } from 'zod'; + +const loginSchema = z.object({ password: z.string().min(1) }); + +export const POST: RequestHandler = async ({ request, cookies }) => { + const body = await request.json().catch(() => null); + const parsed = loginSchema.safeParse(body); + + if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) { + throw error(401, { message: 'Invalid password' }); + } + + cookies.set('admin_token', env.ADMIN_SECRET, { + path: '/', + httpOnly: true, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7 + }); + + return json({ ok: true }); +}; diff --git a/src/routes/projects/animaldex/api/animals/+server.ts b/src/routes/projects/animaldex/api/animals/+server.ts new file mode 100644 index 0000000..49b5028 --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/+server.ts @@ -0,0 +1,73 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { animals, sightings, photos } from '$lib/server/db/schema'; +import { registerAnimalSchema } from '$lib/validation/sighting'; +import { sql } from 'drizzle-orm'; +import { env } from '$env/dynamic/private'; + +const MAX_DISTANCE_METERS = 30_000; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + const parsed = registerAnimalSchema.safeParse(body); + + if (!parsed.success) { + throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' }); + } + + const data = parsed.data; + + const [distRow] = await db.execute(sql` + SELECT ST_Distance( + ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography, + ST_MakePoint(${data.lng}, ${data.lat})::geography + ) AS dist + `); + + const dist = Number((distRow as Record).dist); + if (dist > MAX_DISTANCE_METERS) { + throw error(422, { + message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.` + }); + } + + const result = await db.transaction(async (tx) => { + const [animal] = await tx + .insert(animals) + .values({ + species: data.species, + breed: data.breed, + animalName: data.animalName, + description: data.description, + aiBreedSuggestion: data.aiBreedSuggestion, + aiBreedConfidence: data.aiBreedConfidence + }) + .returning({ id: animals.id }); + + const [sighting] = await tx + .insert(sightings) + .values({ + animalId: animal.id, + reporterName: data.reporterName, + seenAt: new Date(data.seenAt), + lat: data.lat, + lng: data.lng + }) + .returning({ id: sightings.id }); + + await tx.insert(photos).values( + data.photoKeys.map((key, i) => ({ + sightingId: sighting.id, + r2Key: key, + url: `${env.R2_PUBLIC_URL}/${key}`, + sortOrder: i + })) + ); + + return { animalId: animal.id, sightingId: sighting.id }; + }); + + console.log(`[animals] created animal ${result.animalId}, sighting ${result.sightingId}`); + return json(result, { status: 201 }); +}; diff --git a/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts new file mode 100644 index 0000000..7497f0d --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts @@ -0,0 +1,51 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { animals, sightings } from '$lib/server/db/schema'; +import { moderationActionSchema } from '$lib/validation/sighting'; +import { eq, isNull } from 'drizzle-orm'; +import { requireAdmin } from '$lib/server/auth'; + +export const POST: RequestHandler = async (event) => { + requireAdmin(event); + + const body = await event.request.json().catch(() => null); + const parsed = moderationActionSchema.safeParse(body); + + if (!parsed.success) { + throw error(400, { message: 'action must be "accept" or "deny"' }); + } + + const now = new Date(); + const isAccept = parsed.data.action === 'accept'; + + const [updated] = await db.transaction(async (tx) => { + const result = await tx + .update(animals) + .set({ + acceptedAt: isAccept ? now : null, + deniedAt: isAccept ? null : now + }) + .where(eq(animals.id, event.params.id)) + .returning({ id: animals.id }); + + if (result.length && isAccept) { + await tx + .update(sightings) + .set({ acceptedAt: now, deniedAt: null }) + .where(eq(sightings.animalId, event.params.id)); + } + + return result; + }); + + if (!updated) { + throw error(404, { message: 'Animal not found' }); + } + + console.log( + `[moderate] animal ${event.params.id} → ${parsed.data.action}, updated ${updated?.id}` + ); + + return json({ ok: true, action: parsed.data.action }); +}; diff --git a/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts new file mode 100644 index 0000000..9a0c875 --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts @@ -0,0 +1,78 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { animals, sightings, photos } from '$lib/server/db/schema'; +import { addSightingSchema } from '$lib/validation/sighting'; +import { eq, sql } from 'drizzle-orm'; +import { env } from '$env/dynamic/private'; + +const MAX_DISTANCE_METERS = 30_000; + +export const POST: RequestHandler = async ({ request, params }) => { + const body = await request.json().catch(() => null); + const parsed = addSightingSchema.safeParse({ ...body, animalId: params.id }); + + if (!parsed.success) { + throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' }); + } + + const data = parsed.data; + + const [animal] = await db + .select({ id: animals.id, acceptedAt: animals.acceptedAt }) + .from(animals) + .where(eq(animals.id, params.id)) + .limit(1); + + if (!animal) { + throw error(404, { message: 'Animal not found' }); + } + + const [distRow] = await db.execute(sql` + SELECT ST_Distance( + ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography, + ST_MakePoint(${data.lng}, ${data.lat})::geography + ) AS dist + `); + + const dist = Number((distRow as Record).dist); + if (dist > MAX_DISTANCE_METERS) { + throw error(422, { + message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.` + }); + } + + const animalAlreadyAccepted = animal.acceptedAt !== null; + + const result = await db.transaction(async (tx) => { + const [sighting] = await tx + .insert(sightings) + .values({ + animalId: data.animalId, + reporterName: data.reporterName, + seenAt: new Date(data.seenAt), + lat: data.lat, + lng: data.lng, + ...(animalAlreadyAccepted ? { acceptedAt: new Date() } : {}) + }) + .returning({ id: sightings.id }); + + if (data.photoKeys && data.photoKeys.length > 0) { + await tx.insert(photos).values( + data.photoKeys.map((key, i) => ({ + sightingId: sighting.id, + r2Key: key, + url: `${env.R2_PUBLIC_URL}/${key}`, + sortOrder: i + })) + ); + } + + return { sightingId: sighting.id }; + }); + + console.log( + `[sightings] created sighting ${result.sightingId} for animal ${params.id} (auto-accepted: ${animalAlreadyAccepted})` + ); + return json(result, { status: 201 }); +}; diff --git a/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts new file mode 100644 index 0000000..b1265e6 --- /dev/null +++ b/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts @@ -0,0 +1,36 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { sightings } from '$lib/server/db/schema'; +import { moderationActionSchema } from '$lib/validation/sighting'; +import { eq } from 'drizzle-orm'; +import { requireAdmin } from '$lib/server/auth'; + +export const POST: RequestHandler = async (event) => { + requireAdmin(event); + + const body = await event.request.json().catch(() => null); + const parsed = moderationActionSchema.safeParse(body); + + if (!parsed.success) { + throw error(400, { message: 'action must be "accept" or "deny"' }); + } + + const now = new Date(); + const isAccept = parsed.data.action === 'accept'; + + const [updated] = await db + .update(sightings) + .set({ + acceptedAt: isAccept ? now : null, + deniedAt: isAccept ? null : now + }) + .where(eq(sightings.id, event.params.id)) + .returning({ id: sightings.id }); + + if (!updated) { + throw error(404, { message: 'Sighting not found' }); + } + + return json({ ok: true, action: parsed.data.action }); +}; diff --git a/src/routes/projects/animaldex/api/detect-breed/+server.ts b/src/routes/projects/animaldex/api/detect-breed/+server.ts new file mode 100644 index 0000000..9c58bf6 --- /dev/null +++ b/src/routes/projects/animaldex/api/detect-breed/+server.ts @@ -0,0 +1,18 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { detectBreed } from '$lib/server/breed-detector'; +import { z } from 'zod'; + +const schema = z.object({ imageUrl: z.string().url() }); + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + const parsed = schema.safeParse(body); + + if (!parsed.success) { + throw error(400, { message: 'imageUrl is required' }); + } + + const result = await detectBreed(parsed.data.imageUrl); + return json(result ?? { breed: null, confidence: 0 }); +}; diff --git a/src/routes/projects/animaldex/api/map/+server.ts b/src/routes/projects/animaldex/api/map/+server.ts new file mode 100644 index 0000000..d3d1914 --- /dev/null +++ b/src/routes/projects/animaldex/api/map/+server.ts @@ -0,0 +1,127 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { animals, sightings } from '$lib/server/db/schema'; +import { mapQuerySchema } from '$lib/validation/sighting'; +import { and, eq, gte, ilike, isNotNull, isNull, lte, sql } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ url }) => { + const params = Object.fromEntries(url.searchParams); + const parsed = mapQuerySchema.safeParse(params); + + if (!parsed.success) { + throw error(400, { message: 'Invalid query parameters' }); + } + + const q = parsed.data; + + const conditions = [ + isNotNull(animals.acceptedAt), + isNull(animals.deniedAt), + isNotNull(sightings.acceptedAt), + isNull(sightings.deniedAt), + sql`ST_Within( + ${sightings}.location::geometry, + ST_MakeEnvelope(${q.minLng}, ${q.minLat}, ${q.maxLng}, ${q.maxLat}, 4326) + )` + ]; + + if (q.species) conditions.push(ilike(animals.species, `%${q.species}%`)); + if (q.breed) conditions.push(ilike(animals.breed, `%${q.breed}%`)); + if (q.name) conditions.push(ilike(animals.animalName, `%${q.name}%`)); + if (q.reporter) conditions.push(ilike(sightings.reporterName, `%${q.reporter}%`)); + if (q.fromDate) conditions.push(gte(sightings.seenAt, new Date(q.fromDate))); + if (q.toDate) conditions.push(lte(sightings.seenAt, new Date(q.toDate))); + + const rows = await db + .select({ + animalId: animals.id, + species: animals.species, + breed: animals.breed, + animalName: animals.animalName, + sightingId: sightings.id, + seenAt: sightings.seenAt, + reporterName: sightings.reporterName, + lat: sightings.lat, + lng: sightings.lng, + photoUrl: sql`( + SELECT url FROM photos + WHERE sighting_id = ${sightings.id} + ORDER BY sort_order ASC + LIMIT 1 + )` + }) + .from(sightings) + .innerJoin(animals, eq(sightings.animalId, animals.id)) + .where(and(...conditions)) + .orderBy(sightings.seenAt); + + const animalMap = new Map< + string, + { + animalId: string; + species: string; + breed: string | null; + animalName: string | null; + sightings: Array<{ + id: string; + lat: number; + lng: number; + seenAt: Date; + reporterName: string | null; + photoUrl: string | null; + }>; + } + >(); + + for (const row of rows) { + if (!animalMap.has(row.animalId)) { + animalMap.set(row.animalId, { + animalId: row.animalId, + species: row.species, + breed: row.breed, + animalName: row.animalName, + sightings: [] + }); + } + animalMap.get(row.animalId)!.sightings.push({ + id: row.sightingId, + lat: row.lat, + lng: row.lng, + seenAt: row.seenAt, + reporterName: row.reporterName, + photoUrl: row.photoUrl + }); + } + + const features = Array.from(animalMap.values()).map((animal) => { + const sorted = [...animal.sightings].sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime()); + const latest = sorted[0]; + return { + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [latest.lng, latest.lat] }, + properties: { + animalId: animal.animalId, + species: animal.species, + breed: animal.breed, + animalName: animal.animalName, + sightingCount: animal.sightings.length, + trail: animal.sightings.map((s) => ({ + id: s.id, + lng: s.lng, + lat: s.lat, + seenAt: s.seenAt.toISOString(), + reporterName: s.reporterName, + photoUrl: s.photoUrl + })) + } + }; + }); + + console.log( + `[map] query returned ${rows.length} rows for bbox ${q.minLng},${q.minLat} → ${q.maxLng},${q.maxLat}` + ); + console.log(`[map] built ${features.length} features`); + + return json({ type: 'FeatureCollection', features }); +}; diff --git a/src/routes/projects/animaldex/api/upload/+server.ts b/src/routes/projects/animaldex/api/upload/+server.ts new file mode 100644 index 0000000..8e34691 --- /dev/null +++ b/src/routes/projects/animaldex/api/upload/+server.ts @@ -0,0 +1,22 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2'; +import { presignedUploadSchema } from '$lib/validation/sighting'; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + const parsed = presignedUploadSchema.safeParse(body); + + if (!parsed.success) { + throw error(400, { message: 'Invalid request body' }); + } + + const { mimeType } = parsed.data; + + if (!isAllowedMimeType(mimeType)) { + throw error(400, { message: 'Unsupported image type' }); + } + + const result = await createPresignedUpload(mimeType); + return json(result); +}; diff --git a/src/routes/projects/animaldex/layout.css b/src/routes/projects/animaldex/layout.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/src/routes/projects/animaldex/layout.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/src/routes/projects/animaldex/register/+page.svelte b/src/routes/projects/animaldex/register/+page.svelte new file mode 100644 index 0000000..7e8fbc8 --- /dev/null +++ b/src/routes/projects/animaldex/register/+page.svelte @@ -0,0 +1,444 @@ + + + + Animaldex - Log a sighting + + + +
+
+ +

+ {isNewSighting ? 'Log another sighting' : 'Register an animal'} +

+
+ +
+ {#if locationError} +
+

📍 Location required

+

{locationError}

+

+ Go to Settings → Site permissions → Location and allow this site. +

+
+ {/if} + +
+

+ Photos + {#if isNewSighting} + (optional) + {:else} + * + {/if} +

+
+ {#each photoPreviews as preview, i (i)} +
+ + +
+ {/each} + {#if photoFiles.length < 10} + + {/if} +
+

Max 10 photos · JPEG, PNG, WebP, HEIC

+
+ + {#if aiLoading} +
+ Identifying breed… +
+ {:else if aiSuggestion && !aiDismissed} +
+

+ 🤖 AI thinks this is a {aiSuggestion.breed} + ({Math.round(aiSuggestion.confidence * 100)}% confident) +

+
+ + +
+
+ {/if} + + {#if !isNewSighting} +
+

+ Species * +

+
+ {#each COMMON_SPECIES as s (s)} + + {/each} + +
+ {#if species === '__custom__'} + + {/if} +
+ +
+

+ Breed (optional) +

+ +
+ +
+
+

+ Animal name (optional) +

+ +
+
+

+ Description (optional) +

+ +
+
+ {/if} + +
+
+

+ When did you see it? * +

+ +
+
+

+ Your name (optional) +

+ +
+
+ +
+

+ Location * +

+ {#if locationLoading} +
+ Getting your location… +
+ {:else if lat !== null} +
+

Drag the pin to fine-tune (max 30 km from you).

+ {/if} +
+ + {#if submitError} +
+ {submitError} +
+ {/if} + + + +

+ All submissions are reviewed before appearing on the map. +

+
+
diff --git a/src/routes/projects/animaldex/register/+page.ts b/src/routes/projects/animaldex/register/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/src/routes/projects/animaldex/register/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/projects/catch-em-all/+layout.server.ts b/src/routes/projects/catch-em-all/+layout.server.ts new file mode 100644 index 0000000..54048b3 --- /dev/null +++ b/src/routes/projects/catch-em-all/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { user: locals.user ?? null }; +}; diff --git a/src/routes/projects/catch-em-all/+layout.svelte b/src/routes/projects/catch-em-all/+layout.svelte new file mode 100644 index 0000000..801d902 --- /dev/null +++ b/src/routes/projects/catch-em-all/+layout.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {#if data.user} + Logged in as {data.user.name} +
+ +
+ {:else} + Log in + {/if} +
+
+ +
{@render children()}
+
diff --git a/src/routes/projects/catch-em-all/+page.server.ts b/src/routes/projects/catch-em-all/+page.server.ts new file mode 100644 index 0000000..e710b6b --- /dev/null +++ b/src/routes/projects/catch-em-all/+page.server.ts @@ -0,0 +1,110 @@ +import { db } from '$lib/server/db'; +import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema'; +import { availabilityValues } from '$lib/availability'; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './catchemall/$types'; +import { and, eq, inArray } from 'drizzle-orm'; + +// compact wire format for the ~19k game entries — full objects with repeated +// keys and availability strings add megabytes to the page payload +export type WireEntry = [id: number, gameId: number, availability: number, note?: string]; + +export const load: PageServerLoad = async ({ locals }) => { + const pokemonRows = await db.query.pokemon.findMany({ + with: { + entry: true + }, + orderBy: (pokemon) => pokemon.number + }); + const pokemon = pokemonRows.map(({ entry, ...rest }) => ({ + ...rest, + entries: entry.map((e): WireEntry => { + const code = availabilityValues.indexOf(e.availability); + return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note]; + }) + })); + + const games = await db.query.game.findMany({ + orderBy: (game) => [game.generation, game.id] + }); + + let caughtIds: number[] = []; + let selectedGameIds: number[] = []; + if (locals.user) { + const caughtRows = await db + .select({ id: userEntry.pokemonGameEntryId }) + .from(userEntry) + .where(eq(userEntry.userId, locals.user.id)); + caughtIds = caughtRows.map((r) => r.id); + + const gameRows = await db + .select({ id: userGame.gameId }) + .from(userGame) + .where(eq(userGame.userId, locals.user.id)); + selectedGameIds = gameRows.map((r) => r.id); + } + + return { pokemon, games, caughtIds, selectedGameIds }; +}; + +export const actions = { + togglePokemonCaught: async ({ request, locals }) => { + if (!locals.user) return fail(401, { message: 'Log in to track your progress' }); + + const data = await request.formData(); + const entryId = Number(data.get('entryId')); + if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' }); + + // only catchable entries can be toggled — the client disables these + // checkboxes but we can't trust that + const [entry] = await db + .select({ availability: pokemonGameEntry.availability }) + .from(pokemonGameEntry) + .where(eq(pokemonGameEntry.id, entryId)); + if (!entry) return fail(404, { message: 'Unknown entry' }); + if (entry.availability !== 'catchable') { + return fail(400, { message: 'This pokemon is not catchable in this game' }); + } + + const caught = data.get('caught') === 'on'; + + if (caught) { + await db + .insert(userEntry) + .values({ pokemonGameEntryId: entryId, userId: locals.user.id }) + .onConflictDoNothing(); + } else { + await db + .delete(userEntry) + .where( + and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id)) + ); + } + }, + + setGames: async ({ request, locals }) => { + if (!locals.user) return fail(401, { message: 'Log in to set rules' }); + + const data = await request.formData(); + const gameIds = data + .getAll('gameIds') + .map(Number) + .filter((id) => Number.isInteger(id) && id > 0); + + if (gameIds.length > 0) { + const validGames = await db + .select({ id: game.id }) + .from(game) + .where(inArray(game.id, gameIds)); + if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' }); + } + + const userId = locals.user.id; + await db.transaction(async (tx) => { + await tx.delete(userGame).where(eq(userGame.userId, userId)); + if (gameIds.length > 0) { + await tx.insert(userGame).values(gameIds.map((gameId) => ({ userId, gameId }))); + } + }); + } +} satisfies Actions; diff --git a/src/routes/projects/catch-em-all/+page.svelte b/src/routes/projects/catch-em-all/+page.svelte new file mode 100644 index 0000000..beb229b --- /dev/null +++ b/src/routes/projects/catch-em-all/+page.svelte @@ -0,0 +1,330 @@ + + + + catchemall + + +
+

Gotta catch 'em all!

+
+ +
+

+ Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations + and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to + complete. +

+
+ +{#if !data.user} +

+ Log in to save your progress. +

+{/if} + +
+ + +

{caughtCount}/{total} entries caught ({percentage} %)

+ 0 ? caughtCount / total : 0}> +
+ +
(scrollTop = scrollEl?.scrollTop ?? 0)} + class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300" +> + + + + + {#each visibleGames as game (game.id)} + + {/each} + + + + {#if startIndex > 0} + + + + {/if} + {#each visiblePokemon as pokemon (pokemon.id)} + + + {#each visibleGames as game (game.id)} + {@const entry = entryMap.get(pokemon.id)?.get(game.id)} + {@const reason = tooltip(entry, game)} + + {/each} + + {/each} + {#if endIndex < data.pokemon.length} + + + + {/if} + +
Pokemon + {game.name} +
+
+ + {pokemon.number} + {pokemon.name} + {pokemon.type1} + {#if pokemon.type2} + {pokemon.type2} + {/if} +
+
+ {#if entry && reason === null} + toggleCaught(entry.id, e.currentTarget.checked)} + /> + {:else} + + + + {/if} +
+
+ + +

Set rules

+

+ Pick the games you want to catch 'em all in. Only those show up in the table and count towards + your progress. +

+ +
+ + +
+ +
{ + return async ({ result, update }) => { + await update(); + if (result.type === 'success') rulesDialog.close(); + }; + }} + > +
+ {#each gamesByGeneration as [generation, games] (generation)} +
+ + draft.has(g.id))} + onchange={(e) => { + for (const g of games) { + if (e.currentTarget.checked) draft.add(g.id); + else draft.delete(g.id); + } + }} + /> + Gen {generation} + + {#each games as game (game.id)} + + {/each} +
+ {/each} +
+ +
+ + +
+
+
diff --git a/src/routes/projects/catch-em-all/layout.css b/src/routes/projects/catch-em-all/layout.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/src/routes/projects/catch-em-all/layout.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/src/routes/progress/+page.server.ts b/src/routes/projects/progress/+page.server.ts similarity index 100% rename from src/routes/progress/+page.server.ts rename to src/routes/projects/progress/+page.server.ts diff --git a/src/routes/progress/+page.svelte b/src/routes/projects/progress/+page.svelte similarity index 100% rename from src/routes/progress/+page.svelte rename to src/routes/projects/progress/+page.svelte diff --git a/src/routes/progress/[id]/+page.server.ts b/src/routes/projects/progress/[id]/+page.server.ts similarity index 100% rename from src/routes/progress/[id]/+page.server.ts rename to src/routes/projects/progress/[id]/+page.server.ts diff --git a/src/routes/progress/[id]/+page.svelte b/src/routes/projects/progress/[id]/+page.svelte similarity index 100% rename from src/routes/progress/[id]/+page.svelte rename to src/routes/projects/progress/[id]/+page.svelte diff --git a/src/routes/progress/create/+page.server.ts b/src/routes/projects/progress/create/+page.server.ts similarity index 100% rename from src/routes/progress/create/+page.server.ts rename to src/routes/projects/progress/create/+page.server.ts diff --git a/src/routes/progress/create/+page.svelte b/src/routes/projects/progress/create/+page.svelte similarity index 100% rename from src/routes/progress/create/+page.svelte rename to src/routes/projects/progress/create/+page.svelte diff --git a/src/routes/progress/topics/+page.server.ts b/src/routes/projects/progress/topics/+page.server.ts similarity index 100% rename from src/routes/progress/topics/+page.server.ts rename to src/routes/projects/progress/topics/+page.server.ts diff --git a/src/routes/progress/topics/+page.svelte b/src/routes/projects/progress/topics/+page.svelte similarity index 100% rename from src/routes/progress/topics/+page.svelte rename to src/routes/projects/progress/topics/+page.svelte diff --git a/src/routes/progress/topics/[id]/+page.server.ts b/src/routes/projects/progress/topics/[id]/+page.server.ts similarity index 100% rename from src/routes/progress/topics/[id]/+page.server.ts rename to src/routes/projects/progress/topics/[id]/+page.server.ts diff --git a/src/routes/progress/topics/[id]/+page.svelte b/src/routes/projects/progress/topics/[id]/+page.svelte similarity index 100% rename from src/routes/progress/topics/[id]/+page.svelte rename to src/routes/projects/progress/topics/[id]/+page.svelte -- 2.49.1