From a74cd19a17522e3764b33cb1e8e86c912183fdba Mon Sep 17 00:00:00 2001 From: Annie Chen Date: Thu, 16 Apr 2026 18:09:49 -0400 Subject: [PATCH 01/43] scaffold extn --- apps/extension/manifest.json | 36 ++ apps/extension/package.json | 40 ++ apps/extension/public/floating_icon.svg | 27 ++ apps/extension/public/loop_logo.png | Bin 0 -> 2382 bytes apps/extension/public/popup_icon.png | Bin 0 -> 5419 bytes apps/extension/src/App.tsx | 7 + apps/extension/src/background.ts | 2 + .../extension/src/components/BookmarkCard.tsx | 236 ++++++++++ .../extension/src/components/BookmarkView.tsx | 0 apps/extension/src/components/FeedView.tsx | 0 .../src/components/FloatingPanel.tsx | 51 +++ .../src/components/OriginalEmailView.tsx | 0 .../extension/src/components/SearchHeader.tsx | 0 apps/extension/src/components/SearchView.tsx | 0 apps/extension/src/content.css | 2 + apps/extension/src/content.tsx | 43 ++ apps/extension/tsconfig.app.json | 36 ++ apps/extension/tsconfig.json | 7 + apps/extension/tsconfig.node.json | 20 + apps/extension/vite.config.ts | 25 ++ bun.lock | 412 +++++++++++++++++- package.json | 4 +- 22 files changed, 944 insertions(+), 4 deletions(-) create mode 100644 apps/extension/manifest.json create mode 100644 apps/extension/package.json create mode 100644 apps/extension/public/floating_icon.svg create mode 100644 apps/extension/public/loop_logo.png create mode 100644 apps/extension/public/popup_icon.png create mode 100644 apps/extension/src/App.tsx create mode 100644 apps/extension/src/background.ts create mode 100644 apps/extension/src/components/BookmarkCard.tsx create mode 100644 apps/extension/src/components/BookmarkView.tsx create mode 100644 apps/extension/src/components/FeedView.tsx create mode 100644 apps/extension/src/components/FloatingPanel.tsx create mode 100644 apps/extension/src/components/OriginalEmailView.tsx create mode 100644 apps/extension/src/components/SearchHeader.tsx create mode 100644 apps/extension/src/components/SearchView.tsx create mode 100644 apps/extension/src/content.css create mode 100644 apps/extension/src/content.tsx create mode 100644 apps/extension/tsconfig.app.json create mode 100644 apps/extension/tsconfig.json create mode 100644 apps/extension/tsconfig.node.json create mode 100644 apps/extension/vite.config.ts diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json new file mode 100644 index 0000000..df7792d --- /dev/null +++ b/apps/extension/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 3, + "name": "Cornell Loop", + "version": "0.0.1", + "action": { + "default_title": "Cornell Loop", + "default_icon": { + "16": "popup_icon.png", + "32": "popup_icon.png", + "48": "popup_icon.png", + "128": "popup_icon.png" + } + }, + "icons": { + "16": "popup_icon.png", + "32": "popup_icon.png", + "48": "popup_icon.png", + "128": "popup_icon.png" + }, + "background": { + "service_worker": "src/background.ts", + "type": "module" + }, + "content_scripts": [ + { + "matches": [ + "https://mail.google.com/*", + "https://calendar.google.com/*" + ], + "js": ["src/content.tsx"], + "css": ["src/content.css"], + "run_at": "document_idle" + } + ], + "permissions": ["storage", "identity", "tabs"] +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 0000000..44474de --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,40 @@ +{ + "name": "@app/extension", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "lint": "eslint .", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@app/ui": "workspace:*", + "@auth/core": "0.37.0", + "@convex-dev/auth": "^0.0.91", + "convex": "^1.32.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "@types/chrome": "^0.0.320", + "@types/webextension-polyfill": "^0.10.0", + "vite": "^7.3.1", + "vite-plugin-svgr": "^5.2.0", + "vite-plugin-web-extension": "^4.1.1" + } +} diff --git a/apps/extension/public/floating_icon.svg b/apps/extension/public/floating_icon.svg new file mode 100644 index 0000000..bfde99a --- /dev/null +++ b/apps/extension/public/floating_icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/extension/public/loop_logo.png b/apps/extension/public/loop_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..23cccf38730d51b347d3311d11f6f4bf7cbc195b GIT binary patch literal 2382 zcmV-U39PbcutStV2-G~)e;)ArJkX<^U zf-93xkeRiM$*hc`Ot%jRC}doSJ7@9*bX)~-krqS*C3Zk0sEbx`<7RsB(dnM9dp!49 zw`MY9RdsSmPrdIwzkA;22H_qMb)W43-~~l_M-ik5m=e$k2nYOh08)I`>VDUUyGRIk zk*N3F3J`VqG|d?S5tV14(%XHK$?8J)+X37Kx+_4adpZM{ZSZJkm|0W|K&gUjM6{s% zj1eG#(3Nl9=)s&cHxLVA_c{-ic`o+?DNR@c#qaaEKpY5>pT+KA1Pw{R98?COxihL% zMxdERw_b*CGGNewFyjEUXG&hDZaamB)EI~XK@5U0)65Z}6$kB=1OEGYB7e~eDk zh}wBBuRud;E)m*YV+JzK5(W%}dcj%m$qONtkC8@kQ9SpOL0X!=`OHOVK#go<_U1E7 ztk)mwsm%(5TWyF994J?S6idjf(;kDjkAm%t5~)tswU*x-O-L+TPn4H zOuNsud8T#*#5vr;5ZGWhTW{Uy9yU%Q={$c`UhSZ6{syi<4XOo1s(h(11F)8?|9MO0 zpFs7WQQD}Cf)DHkmTbki4Xa-1uF2V|Q7s_M=cytllrt%98T`A-Ob)Azl_YB2NQH_x z<0c6LX1CXdid5G|I$=JCg~(5?_^3XsI*^tMe|6~ltfn2PNR@$1yY04DA3KZ5HLO;+ zdop2nTZahycf9XX7)f{vDpF-2Vy2_GRV9~VV33NvKd2kTT2l2(HzLpF=E$HTRR)3r z9`SL*r$^ubIf*pMT#IjtQA@;$WCj(eG7zO8b-67$X+Xq;)`TI{BqKrZhKXPvst&|? zTbERM*Gq9)0u6}28krIO51>&7nACbZI9-dyvEnKbY?OfVS1ADQalufVYN%E|bhSkx zhNyn!vlGySNCP>znygBdfwU6X_M`jUoXn}mLzP{nCzJ9Br&B?#${BIh09#!mGx;ld zyKrTm2V~@d?4E0uszR#Fa3H_}5k*OC4piNNRF}vC3~lVwS5zd7cHPX>(@7Ees}Si- zK{L-k6v7!iMfn#FtBzGYj+m4JZfdXP4a0!}SLX5~bp~n>O7U{=RvL`7puChw(#@)4 z)k_)yzARO)I`NG?FxT8w)oc1L+XrHX0f4D#G&t~5;y+tZk?H{vdSC+2yusmpi_PfM zeB$ZpKI;W$IyJLP&wP9|5~-|KcF3B<(`=y= zLTn#CIJ-{b#zEg}X>gaQ8;skim2fKNKp+m2*S_6^dkJLm@(+Dx^-IH)Z;<*b4}HIl zvdN_<*Y5PaYKb*(7v#N_yY@!UvaThKMnEi44}#|{_7BLlfAvWY(4D^iM5$DreRz6_ zUt2PTlaC#`EooFt*rUdh$Pb_Hj$+mOH^wH^zUF%x# zRBrXXhbp9mJzV`}2sNp3?>EnB)f)ws+KdqOa^d8{c=rlzbW)P?IrH4t zbM22zCbGxO4okZ_A%C$hE&1P3!24QRIZn89gxKBon}V1d$cgpAC`VlO>AVRlZZk*U zYdyHgK<e;c|ZnbI@Ei64PI=KEA;WFKG!R|6jCBmh8a)l<@*V7nSPC<*&LhCxx1wK0TMh z(ZA1`3&}mI7r&^yZA_nb+G?yudZmSq2L~9ly~iR@cIPVJp3vm?zaAK0dMsPKd}~-` zu0J{bVa3gO0Y)uxWUzSIR**Jv{rvegZj*HFtG=h8hO85P(*{k%Fa}1~IvMOr-FZC4 z@vy^6)1iF~L(x!B&WAFrnNrx{EFcZl zo$)EyJ^ucWJ)~l4~+F#=!Ws{NAK}HWk7Hr zW2Vdw7eWzoC_)soK+!fI`ksL;uwAu^YiuLfF74^Lc0!r_s#3X$Mi>FH4Ey?*;!RKY zZT`m?Ccpm4>SCp4x9b4QS%JTS#ca`Y3SAsu5}7rAgn^K5S*GEG047$i2xu75t0EN& zp&&4i#;@9Nt;RXr{&^enooN_N`I*RXvzzpv+zpev39}ceXz#q_un6k|xh}#Tb}@SA z2=~}Wyh0Ix;6kJ4J__oej4C}1PH;O^h~D_Oou}2#v}ZA5Y-)vkjsCl;IanEhLx)r< z;>an4%~ny-IJrOTebd2O(=EMCpk9=mwYqojpy<5vcX2yF7;t9CdM3{zEkN(d{iQ(8x3y;?a zy$dxywu$Gr4%}uifCOk)dO^=;hlv&<=#@Q1Z{LmqHjNRh(iU6D)#LBq>H{1Q+3DKz z9kAPMXFxZZi5WBmc-a1$=3~yy#xgzjxHpj<6#=N&o-=07*qoM6N<$f~+)e Aw*UYD literal 0 HcmV?d00001 diff --git a/apps/extension/public/popup_icon.png b/apps/extension/public/popup_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..db93ef685dccd5ea49a7d430cf97b1e72acd892d GIT binary patch literal 5419 zcmV+`71Zj9P)qa2X~+}pdo-Mzct&Fsw1&h2xw zgyg+Fx_Sod3Tuh7SvSzY+whinf(K?rF$7h+St0#5enQLVi%gQ_3E>3Bgb||3* zQn;Wz5cbWnd-Tyqof6tcf;?=k9$w38nMWRZ1Y4qc=HVnCb+j!74|TK*Qf)^8tPomFd8KM<)EEGTs93Dy@j9rlchM$_6 zayW;4+0%~^LRD}Gv#N+q1Ub;kZ&7E z6D!?(Q3TS&knm!7m zD;$W!=l}4wt+!rR`9g)<-Lyi8f$ZWW1$RPnkoWMz52wnV^D3JRa3KXT;Rcek5AA+% z$92_bR}zSzp`jts1&0qGPE^5>=`X9r3f2r@07Z-eBKnSJ?(O`ut_nRwE0DO4t}>W) z?lV0rD$x~zggbMh9h6G!6Q6qOsf2+{xL-`hKXLoVd#dhJv_fg{?+*>IQk&)721{mx zF-aM(hT6V4j&w#Hfg7PvCMPG8Z~o5Ct=$#3A~{hhP1B^mzrWJa(E*DfMP9IVMT^Hi zGT4aNbpR24!KqWHDo2hSsqEa?`AII@M=O{Zo64CpXB-3ShKGm6?1KfP#(e}*nGqmD zJ9A%<)a!Nkf4}y=tzA`mfL1u2KYu>y?d=t#9Js))MLuh8;l3!^YIMW!gm#9Jiw^d- z&4YVsm8iSBTRb4P|m=mXFfFzQJyNp{jIML&Zw9h-y1W{~pfzjO2vFnW~kQx9EDTDx|w&K-%?R{tdOs&;}09%2TPz_x5h zUOg}wyySf!B)9!T0;DB>^kwn~_fz`nSLteyc6adTkBp2&M^80OC<%xM4jd4dziKAS z&dWu~9UrA+WbeG(=Z1F5mi+8Tsqy8{2(ZgZ;6BzP!jW(>hU}~l)bS!zI&36pCe&d~ zwR{}Oz*X-13kK+80Yrc4^317Ho`GKuVk`ti*pV!?#hY`skCb5#qwOKuY<&9V)Y(bA;NoZCllL#HJ0QngajKcZEMXWfRv7In16X6^JNPYho&A&T zZ&1mU{LKB#S^L6~Zzh#mc8jqekMsLfocC{h@sp*BdGDtW-pKajG&9%?OKqJQuFlyF zY~r*Cj!F0+VJf zkdp`l-;DWyF)ptd<4U@0M|I^?`QYy|aP_b;cnwZHfXwpJm;Z+vPyQuy+$YG79m%+J z&c?#jw~3P5KSE^^FbT>AbLf(%&?Oo|66{FHMIXr=S-P@G-g_4tXZ-;itoI-OX+^_2p)> zh+%XS#TcRuFv}!&-B1Qzc0|%0<33nA($Oa`S=Yb$68Rs!#D@7Un=9?OP1~p}TDNYU z1=Hz8Wvs2bCX5^9q~XZY#M@oJE!Q7LuADkg>G!{R+4|`#UkQw86b@4EBi)AgP#I*s zVMf^iBcBu|y`u9H{Xv<^#`SN`KqRYR8sty4XSVt`UZlCMfDBZ(aVsq~sXtOBRUNzP zgVX>^Q8w_7LnDDp%jQU`bv6Ms8rz?Gv%UIfPY_LCXl)L4Eq(vaePn!V&?CTV!-fsQ zjRbkH^EE)3otCzKdFD^8qbb&Qo1SdD2|qJQc{3<%UF&G631e+YDfEZ+kSlhgY@ouy zmKvzJECk~lzy$>(B>U+C8ZjWca;-It@*t%UZGZ|2-k&T$z(iO`03}4*3=PY>yC}{! zk1~;Olbncf(;zRC6g&__8`6te#QppCOTeir8}a@i{8#PBV0@N$cMxH#wS%&8J-zMK z?_8USu=I}Hz2`2v+i@QA5DQU@|3ILWuQqU!<}~ZENclQ)MIfg&+H6m!&e5jZ*r(=bAHz-g0M3$<4b( ziz~?_*{%$zze6nZewOfbnEsJ9}kUw>dIrGh?(xJ4;q17r? ze)^X<<#Bo6W=ekHw{m7q24-gdr05$N%(4mC6r<7ftA?ETq9au|+Hz6)+GumiK|u`1 zZfze`K5!3JfBiE&nptPK5ES;|QGfOcDw9+?!I7{d;g^7D!%v|>RB|_9Q;cj-NZn}5 z6(HbGzm;ilBi0enN|oH5zs*k4Elm((zevK7>Hq&Dn^Na!IY|3R6>S?IDHMEbq)v&>-n~RUgCWnExPT1Q^qGI7^o9SXvI&@AhzO1ZF5TVT zaswJ4Ij4_A5G7I=pQR2Fi&USX`nP$)Yvy9ciTzuNc72RN+(2Y~joL?o#*b&*xC{jF z@Fg7ffe|bmWj-6$Cqu;W@wd*|cy+H|3Ay!~$@4r6O|>C<7bht_KSRk~4-oYZQ7$?q zdj0~jD5p<8(pI~!E8}uMb>&_m4Wfe0t)GWrh^S-j$ayN-R~#S709Nk(b8^GCXUE{4 z?|zN^4-fD@YwEXN6E~QHEA0APq+D{_hg*BMRV5|?&DklQ5B+yawr-dB3kyccDY!qYGY3?Ium@8qY;jPIRvSeowgS^K(xgzt2IgHldKtyw$3i&*u+{4?v zIb~fIRCVBSqI1XKrlx$TOWHd0MfbyOrR!Z!u5;l^6n&)H)`%gu(=w3gs?KZg+qVyW zB>G1|7L^*f*~Jk0#O7s4*g{^D>GbqC=7(~Hc%DdS4ig>x61P1bo5;A$&n+7->zmiG zV!@(M(+fB4bR8l!$Tju{nbqPaAdr7MFkcj*4?OZ6a>Kjz77EYlsP$A!3uBwxYW&Be zOqow`eh>wzV5e5M2SAM{U`f+U~>|*&@N@sZ^ThnfsQKY=A&VM+d%; z%XxnQqz_Bd_=5)zdi!|@U5Onz4~hLKG)hZKY(x6`(!Bguq<#lgHqhD#>$CnyS$ai1 z{p4J=8q~HU6xGaBcmHlBX%{2D%4+cj{FHG!9tY{hSTQ5cNzU-h$Hf@;k-+s zwXa3eJ~Di;AQ8Jt+DAr_)1lvYUy-XCyenEXOk9LvO20uWh+J~E6W<8Wa%O;>@W z%=`gXHqrtnBcxfCjS$9-aOz@zU|=9FGI{z!<5^mz9D&&|zoh{JGK&~ue*`qFX^|v} z?6X^4z#?b?oBjXa$o7<3oSIM1-<|rK6$aF^CETOI<_z_%IFJ ziZs3BA6|d__0!WU8ccAcinfM6>$-Bed2Oh3XzrkJkT!U$b3vK(xb?FyfA+<ck=mK*gF2;_JMc(@tqrg zfAiW}xnG*UgQ)k@2ZwkncDh!p&2)8j)fvz_GhG9^MNA+qsD?RDf+Z~bm7$>_fQZF7 z0c1y-rUGJ^=fPqVmEU{+z>N>ww*Eu!>0h&_w==o9;%MnVxq63C-`~8t1wfuXdse1m zv0(A`?b{o{L+*y+jr$Gh$ z*04{UlBS?So)6`P<|!*>G|!T4E7Xr=OkZp?+7bm0aSSfm~)>V`C4z5tk(Ap?*%FUg0O7V8)5gz^fMGl0i?F@XDU@5pNa zM5O$Dv%J+=W2elKqJSAhF%|dj-P?@)iFY05Y;`$k?nph4Zc;3Rhz4;n)>x{95Eq1H zS{h*?I=eXq6H84ak8jFjo`X~5<=!2cN!4)Po4R%qp0sXAt@GKNwzYn|2%n4BL%!Zi zICeN6t_K2Rq|Au`5!_gZ(uKkuA0L-Zcx#@$ zA23culQWDmS&eZ8$)G3Ax3U|Vcz)P{QmtkTlf~@C4>NUJjO_0K|)AHft0Xeqq z2XoGGD*561kSb^ICA844q zLmjjsWl+bqiMC8Rtt(|w&s=lLv^AyILwg|)&mqsm`?K7o%`#~9v9Yl_1DJB(m|_H| zA1p;*1zm!SZK-9&&J2I#gVhG0Ty%YI44-1WU~tL9e;2z9O?44F6xD@D;O9fW`ulhv zc@*}goX`LNS3E{pGJU3A6Ao(Kx6h^5v-!c9Z3A6|>lnzCm2C=%1V?I@r-1>1gD*cz z7?wp8&Nct6B*)yiqySaF%VXA;6vHSi4~WPu3^_Ft7z9^6jx{ah$5oZJK1c(X;GNcd zq)K`nJ&(3@l1l62TIPQ2b>zkBDttJ{&*Q`M$p6>i{~0jy!@jy54e;$vw;E2+Ixc)D zFP?RT1Y^nlU=}$ATuE0X383eW=c88T)Z;Q=eXSGzs>*5EFbD0lo;`2dvwx3;=~g@s z7H~$_##TjLyA1Uc3tyPSIc7`=dh1^2xYac)ZPdDtvGMX;9T&- ziY`DK3?e{l?BBm%EEg4LeA`~^F0P1ti@)~Me{0iy-Hb`3Gna`iPt88KWnKNpJ9|!# zZJxO&r`MtIFLL%8TBdLhxi;*x_^Y@Ac2P;WLO#uNTs$}Dy!XDXa)tA>(CaLOehf_R VyuAyyjyV7T002ovPDHLkV1i7(kn#Wk literal 0 HcmV?d00001 diff --git a/apps/extension/src/App.tsx b/apps/extension/src/App.tsx new file mode 100644 index 0000000..1fe85bd --- /dev/null +++ b/apps/extension/src/App.tsx @@ -0,0 +1,7 @@ +export default function App() { + return ( +
+

Cornell Loop

+
+ ) +} diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts new file mode 100644 index 0000000..53bbd97 --- /dev/null +++ b/apps/extension/src/background.ts @@ -0,0 +1,2 @@ +// No side panel logic needed — UI is injected via content script. +export {} diff --git a/apps/extension/src/components/BookmarkCard.tsx b/apps/extension/src/components/BookmarkCard.tsx new file mode 100644 index 0000000..629dc1f --- /dev/null +++ b/apps/extension/src/components/BookmarkCard.tsx @@ -0,0 +1,236 @@ +/** + * BookmarkCard — Extension UI + * + * Source: Figma "Incubator-design-file" › Popup › Bookmarks view + * Node: 554:5543 (single card instance) + * + * Layout: + * ┌───────────────────────────────────────┐ + * │ [avatar] Org name │ ← org header + * │ ──────────────────────────────────── │ + * │ [DateBadge] Title [bookmark] │ ← event row + * │ Subtitle line 1 │ + * │ Subtitle line 2 │ + * │ [Tag] [Tag] │ ← tags (optional) + * │ ───────────────────────────────── │ + * │ [ RSVP ] [ Add to Calendar ] │ ← actions (each conditional) + * └───────────────────────────────────────┘ + * + * Uses design system components: DateBadge, Tag, Button + * Uses design system SVG: bookmark-filled.svg (always filled — this is the + * bookmarks view so every card is already saved) + * + * Subtitle variants (from Figma annotations): + * events → string[] e.g. ['4:00 pm – 5:30 pm', 'Hollister Hall 312'] + * informative → string e.g. 'For early career designers and developers' + * edge case → string e.g. 'Click to see original email' + */ + +import type { ComponentPropsWithoutRef } from 'react' +import { DateBadge, Tag, Button } from '@app/ui' +import type { ThumbnailVariant } from '@app/ui' +import BookmarkFilledIcon from '@app/ui/assets/bookmark-filled.svg?react' + +// ── Typography shared strings ────────────────────────────────────────────── + +const BODY2_SEMIBOLD = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[length:var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]' + +const BODY3 = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[length:var(--font-size-body3)] leading-[var(--line-height-body3)] ' + + 'tracking-[var(--letter-spacing-body3)]' + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface BookmarkCardProps extends ComponentPropsWithoutRef<'div'> { + /** Organisation name shown in the card header. */ + orgName: string + /** URL for the org's circular avatar. Falls back to an initials badge. */ + orgAvatarUrl?: string + /** Controls the DateBadge thumbnail style. Defaults to "date". */ + thumbnailVariant?: ThumbnailVariant + /** Day-of-month for the "date" thumbnail (e.g. 24). */ + day?: number | string + /** Abbreviated month for the "date" thumbnail (e.g. "Mar"). */ + month?: string + /** Event title — DM Sans SemiBold 14 px, Neutral/900. */ + title: string + /** + * Subtitle shown below the title in the event row. + * string → single line (informative summary or edge-case message) + * string[] → multiple lines, e.g. ['4:00 pm – 5:30 pm', 'Hollister Hall 312'] + */ + subtitle?: string | string[] + /** Neutral/200 category tags shown beneath the event row. */ + tags?: string[] + /** + * When provided, an RSVP button is rendered. + * Figma annotation: "appears only if there's an RSVP link". + */ + onRsvp?: () => void + /** + * When provided, an Add to Calendar button is rendered. + * Figma annotation: "appears only when there's specific date and time". + */ + onAddToCalendar?: () => void + /** Called when the filled bookmark icon is pressed to remove the save. */ + onUnbookmark?: () => void +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function BookmarkCard({ + orgName, + orgAvatarUrl, + thumbnailVariant = 'date', + day, + month, + title, + subtitle, + tags, + onRsvp, + onAddToCalendar, + onUnbookmark, + className, + ...rest +}: BookmarkCardProps) { + const subtitleLines: string[] = + subtitle == null ? [] : Array.isArray(subtitle) ? subtitle : [subtitle] + + const hasActions = onRsvp != null || onAddToCalendar != null + + return ( +
+ {/* ── Org header ── */} +
+ {/* 32 px avatar circle */} +
+ {orgAvatarUrl ? ( + {orgName} + ) : ( + + {orgName.charAt(0).toUpperCase()} + + )} +
+ + {/* Org name — DM Sans SemiBold 14 px, Neutral/700 */} + + {orgName} + +
+ + {/* ── Middle: event row + tags ── */} +
+ {/* Event row — DateBadge + text + filled bookmark */} +
+ + + {/* Title + subtitle */} +
+

+ {title} +

+ + {subtitleLines.length > 0 && ( +
+ {subtitleLines.map((line, i) => ( +

+ {line} +

+ ))} +
+ )} +
+ + {/* Bookmark — always filled (orange) since this card is in the saved list */} + +
+ + {/* Tags */} + {tags && tags.length > 0 && ( +
+ {tags.map((label) => ( + + {label} + + ))} +
+ )} +
+ + {/* ── Action buttons ── */} + {hasActions && ( +
+ {onRsvp && ( + + )} + {onAddToCalendar && ( + + )} +
+ )} +
+ ) +} diff --git a/apps/extension/src/components/BookmarkView.tsx b/apps/extension/src/components/BookmarkView.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/extension/src/components/FeedView.tsx b/apps/extension/src/components/FeedView.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/extension/src/components/FloatingPanel.tsx b/apps/extension/src/components/FloatingPanel.tsx new file mode 100644 index 0000000..60b7408 --- /dev/null +++ b/apps/extension/src/components/FloatingPanel.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import App from '../App' +import FloatingIcon from '../../public/floating_icon.svg?react' +import CloseIcon from '@app/ui/assets/close_search.svg?react' + +export default function FloatingPanel() { + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + {/* Floating tab — SVG is the full visual (bg, shape, shadow all included) */} + {!isOpen && ( + + )} + + {/* Side panel — slides in from the right */} +
+ {/* Panel header */} +
+ Cornell Loop + +
+ + {/* Panel content */} +
+ +
+
+ + ) +} diff --git a/apps/extension/src/components/OriginalEmailView.tsx b/apps/extension/src/components/OriginalEmailView.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/extension/src/components/SearchHeader.tsx b/apps/extension/src/components/SearchHeader.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/extension/src/components/SearchView.tsx b/apps/extension/src/components/SearchView.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/extension/src/content.css b/apps/extension/src/content.css new file mode 100644 index 0000000..4f5474f --- /dev/null +++ b/apps/extension/src/content.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import '@app/ui/styles/tokens.css'; diff --git a/apps/extension/src/content.tsx b/apps/extension/src/content.tsx new file mode 100644 index 0000000..73d874d --- /dev/null +++ b/apps/extension/src/content.tsx @@ -0,0 +1,43 @@ +import { ConvexAuthProvider } from '@convex-dev/auth/react' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { ConvexReactClient } from 'convex/react' +import FloatingPanel from './components/FloatingPanel.tsx' +import contentStyles from './content.css?inline' + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string) + +function mount() { + if (document.getElementById('cornell-loop-host')) return + + const host = document.createElement('div') + host.id = 'cornell-loop-host' + // Explicit styles prevent Gmail/Calendar from accidentally hiding or + // reflowing the host. Fixed + zero-size keeps it out of the page layout + // while still allowing the shadow DOM children to use fixed positioning. + host.style.cssText = + 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;overflow:visible;pointer-events:none;' + document.body.appendChild(host) + + const shadow = host.attachShadow({ mode: 'open' }) + + // Inject processed Tailwind + token styles into the shadow root so they + // aren't blocked by shadow DOM style encapsulation. + const sheet = new CSSStyleSheet() + sheet.replaceSync(contentStyles) + shadow.adoptedStyleSheets = [sheet] + + const mountPoint = document.createElement('div') + mountPoint.style.pointerEvents = 'auto' + shadow.appendChild(mountPoint) + + createRoot(mountPoint).render( + + + + + , + ) +} + +mount() diff --git a/apps/extension/tsconfig.app.json b/apps/extension/tsconfig.app.json new file mode 100644 index 0000000..9c84aa7 --- /dev/null +++ b/apps/extension/tsconfig.app.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client", "vite-plugin-svgr/client", "chrome"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Path aliases */ + "paths": { + "@app/ui": ["../../shared/ui/src/index.ts"], + "@app/ui/*": ["../../shared/ui/src/*"], + "@app/convex": ["../dashboard/convex"], + "@app/convex/*": ["../dashboard/convex/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "../../shared/ui/src"] +} diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/extension/tsconfig.node.json b/apps/extension/tsconfig.node.json new file mode 100644 index 0000000..f50c446 --- /dev/null +++ b/apps/extension/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts new file mode 100644 index 0000000..8096df5 --- /dev/null +++ b/apps/extension/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import svgr from 'vite-plugin-svgr' +import webExtension from 'vite-plugin-web-extension' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + svgr(), + webExtension({ manifest: './manifest.json' }), + ], + resolve: { + alias: { + '@app/ui': path.resolve(__dirname, '../../shared/ui/src'), + '@app/convex': path.resolve(__dirname, '../dashboard/convex'), + }, + dedupe: ['react', 'react-dom'], + }, +}) diff --git a/bun.lock b/bun.lock index e67a799..482e1c6 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,38 @@ "vite": "^7.3.1", }, }, + "apps/extension": { + "name": "@app/extension", + "version": "0.0.0", + "dependencies": { + "@app/ui": "workspace:*", + "@auth/core": "0.37.0", + "@convex-dev/auth": "^0.0.91", + "convex": "^1.32.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.2", + "@types/chrome": "^0.0.320", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/webextension-polyfill": "^0.10.0", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vite-plugin-svgr": "^5.2.0", + "vite-plugin-web-extension": "^4.1.1", + }, + }, "shared/ui": { "name": "@app/ui", "version": "0.0.0", @@ -51,6 +83,8 @@ "packages": { "@app/dashboard": ["@app/dashboard@workspace:apps/dashboard"], + "@app/extension": ["@app/extension@workspace:apps/extension"], + "@app/ui": ["@app/ui@workspace:shared/ui"], "@auth/core": ["@auth/core@0.37.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "@types/cookie": "0.6.0", "cookie": "0.7.1", "jose": "^5.9.3", "oauth4webapi": "^3.0.0", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg=="], @@ -87,6 +121,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -95,6 +131,12 @@ "@convex-dev/auth": ["@convex-dev/auth@0.0.91", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.37.0", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg=="], + "@devicefarmer/adbkit": ["@devicefarmer/adbkit@3.3.8", "", { "dependencies": { "@devicefarmer/adbkit-logcat": "^2.1.2", "@devicefarmer/adbkit-monkey": "~1.2.1", "bluebird": "~3.7", "commander": "^9.1.0", "debug": "~4.3.1", "node-forge": "^1.3.1", "split": "~1.0.1" }, "bin": { "adbkit": "bin/adbkit" } }, "sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw=="], + + "@devicefarmer/adbkit-logcat": ["@devicefarmer/adbkit-logcat@2.1.3", "", {}, "sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw=="], + + "@devicefarmer/adbkit-monkey": ["@devicefarmer/adbkit-monkey@1.2.1", "", {}, "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -193,6 +235,12 @@ "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], + + "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], + + "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.2", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -309,18 +357,30 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chrome": ["@types/chrome@0.0.320", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-LD0O6hqayM4/InMUB2fOGSh7lFkUlusa40mk6iUAxs0RHfvRYjjsDoRGsDh2DEJWP4Ckccf00FVhkhDHHIqy7g=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], + + "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], + + "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], + "@types/node": ["@types/node@24.10.15", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/webextension-polyfill": ["@types/webextension-polyfill@0.10.7", "", {}, "sha512-10ql7A0qzBmFB+F+qAke/nP1PIonS0TXZAOMVOxEUsm+lGSW6uwVcISFNa0I4Oyj0884TZVWGGMIWeXOVSNFHw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], @@ -347,20 +407,46 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "array-differ": ["array-differ@4.0.0", "", {}, "sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw=="], + + "array-union": ["array-union@3.0.1", "", {}, "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -369,44 +455,88 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="], + + "chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], + + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + + "configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], "cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], + "dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], @@ -437,48 +567,106 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "firefox-profile": ["firefox-profile@4.7.0", "", { "dependencies": { "adm-zip": "~0.5.x", "fs-extra": "^11.2.0", "ini": "^4.1.3", "minimist": "^1.2.8", "xml2js": "^0.6.2" }, "bin": { "firefox-profile": "lib/cli.js" } }, "sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ=="], + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fx-runner": ["fx-runner@1.4.0", "", { "dependencies": { "commander": "2.9.0", "shell-quote": "1.7.3", "spawn-sync": "1.0.15", "when": "3.7.7", "which": "1.2.4", "winreg": "0.0.12" }, "bin": { "fx-runner": "bin/fx-runner" } }, "sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graceful-readlink": ["graceful-readlink@1.0.1", "", {}, "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w=="], + + "growly": ["growly@1.3.0", "", {}, "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + + "is-absolute": ["is-absolute@0.1.7", "", { "dependencies": { "is-relative": "^0.1.0" } }, "sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + + "is-installed-globally": ["is-installed-globally@1.0.0", "", { "dependencies": { "global-directory": "^4.0.1", "is-path-inside": "^4.0.0" } }, "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ=="], + "is-network-error": ["is-network-error@1.3.1", "", {}, "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw=="], + "is-npm": ["is-npm@6.1.0", "", {}, "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA=="], + + "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + + "is-primitive": ["is-primitive@3.0.1", "", {}, "sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w=="], + + "is-relative": ["is-relative@0.1.3", "", {}, "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], @@ -491,7 +679,7 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -499,12 +687,24 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], + + "latest-version": ["latest-version@9.0.0", "", { "dependencies": { "package-json": "^10.0.0" } }, "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -529,12 +729,18 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="], + + "linkedom": ["linkedom@0.14.26", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^8.0.1", "uhyphen": "^0.2.0" } }, "sha512-mK6TrydfFA7phrnp+1j57ycBwFI5bGSW6YXlw9acHoqF+mP/y+FooEYYyniOt5Ot57FSKB3iwmnuQ1UUyNLm5A=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + + "lodash.uniqby": ["lodash.uniqby@4.7.0", "", {}, "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="], + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -543,29 +749,53 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], + + "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], + + "md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multimatch": ["multimatch@6.0.0", "", { "dependencies": { "@types/minimatch": "^3.0.5", "array-differ": "^4.0.0", "array-union": "^3.0.1", "minimatch": "^3.0.4" } }, "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "node-notifier": ["node-notifier@10.0.1", "", { "dependencies": { "growly": "^1.3.0", "is-wsl": "^2.2.0", "semver": "^7.3.5", "shellwords": "^0.1.1", "uuid": "^8.3.2", "which": "^2.0.2" } }, "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "os-shim": ["os-shim@0.1.3", "", {}, "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "package-json": ["package-json@10.0.1", "", { "dependencies": { "ky": "^1.2.0", "registry-auth-token": "^5.0.2", "registry-url": "^6.0.1", "semver": "^7.6.0" } }, "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -579,6 +809,12 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pino": ["pino@9.7.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="], @@ -591,34 +827,96 @@ "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "promise-toolbox": ["promise-toolbox@0.21.0", "", { "dependencies": { "make-error": "^1.3.2" } }, "sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg=="], + + "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], + + "registry-url": ["registry-url@6.0.1", "", { "dependencies": { "rc": "1.2.8" } }, "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], + "set-value": ["set-value@4.1.0", "", { "dependencies": { "is-plain-object": "^2.0.4", "is-primitive": "^3.0.1" } }, "sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.7.3", "", {}, "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw=="], + + "shellwords": ["shellwords@0.1.1", "", {}, "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="], + "snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="], + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "spawn-sync": ["spawn-sync@1.0.15", "", { "dependencies": { "concat-stream": "^1.4.7", "os-shim": "^0.1.2" } }, "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw=="], + + "split": ["split@1.0.1", "", { "dependencies": { "through": "2" } }, "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-bom": ["strip-bom@5.0.0", "", {}, "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], + + "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="], @@ -627,48 +925,102 @@ "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "update-notifier": ["update-notifier@7.3.1", "", { "dependencies": { "boxen": "^8.0.1", "chalk": "^5.3.0", "configstore": "^7.0.0", "is-in-ci": "^1.0.0", "is-installed-globally": "^1.0.0", "is-npm": "^6.0.0", "latest-version": "^9.0.0", "pupa": "^3.1.0", "semver": "^7.6.3", "xdg-basedir": "^5.1.0" } }, "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite-plugin-svgr": ["vite-plugin-svgr@5.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.3.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=3.0.0" } }, "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ=="], + "vite-plugin-web-extension": ["vite-plugin-web-extension@4.5.1", "", { "dependencies": { "ajv": "^8.11.0", "async-lock": "^1.3.2", "fs-extra": "^10.1.0", "json5": "^2.2.3", "linkedom": "^0.14.21", "lodash.uniq": "^4.5.0", "lodash.uniqby": "^4.7.0", "md5": "^2.3.0", "vite": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.0.0 || ^4.1.4", "web-ext-option-types": "8.3.1", "web-ext-run": "^0.2.1", "webextension-polyfill": "^0.10.0", "yaml": "^2.3.4" } }, "sha512-7AZGrRVn0O7YGwT+rHCOufPrDd4zff2NZM1hdJCpaUO8u2fmFaJOCsuEUoFFLa8Sk/qet7G1ZC5MYT9wgGGXDw=="], + + "watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="], + + "web-ext-option-types": ["web-ext-option-types@8.3.1", "", {}, "sha512-mKG1fplVXMKYaEeSs35v/x9YIx7FJJDCBQNoLoMvUXeFck0rNC2qnHsYaRnVXXd1XL7o/hz+5+T7YqpTVyEK3w=="], + + "web-ext-run": ["web-ext-run@0.2.4", "", { "dependencies": { "@babel/runtime": "7.28.2", "@devicefarmer/adbkit": "3.3.8", "chrome-launcher": "1.2.0", "debounce": "1.2.1", "es6-error": "4.1.1", "firefox-profile": "4.7.0", "fx-runner": "1.4.0", "multimatch": "6.0.0", "node-notifier": "10.0.1", "parse-json": "7.1.1", "pino": "9.7.0", "promise-toolbox": "0.21.0", "set-value": "4.1.0", "source-map-support": "0.5.21", "strip-bom": "5.0.0", "strip-json-comments": "5.0.2", "tmp": "0.2.5", "update-notifier": "7.3.1", "watchpack": "2.4.4", "zip-dir": "2.0.0" } }, "sha512-rQicL7OwuqWdQWI33JkSXKcp7cuv1mJG8u3jRQwx/8aDsmhbTHs9ZRmNYOL+LX0wX8edIEQX8jj4bB60GoXtKA=="], + + "webextension-polyfill": ["webextension-polyfill@0.10.0", "", {}, "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g=="], + + "when": ["when@3.7.7", "", {}, "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw=="], + + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "winreg": ["winreg@0.0.12", "", {}, "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zip-dir": ["zip-dir@2.0.0", "", { "dependencies": { "async": "^3.2.0", "jszip": "^3.2.2" } }, "sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "@convex-dev/auth/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "@devicefarmer/adbkit/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -689,10 +1041,54 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "boxen/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "dot-prop/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "firefox-profile/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], + + "fx-runner/commander": ["commander@2.9.0", "", { "dependencies": { "graceful-readlink": ">= 1.0.0" } }, "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A=="], + + "fx-runner/which": ["which@1.2.4", "", { "dependencies": { "is-absolute": "^0.1.7", "isexe": "^1.1.1" }, "bin": { "which": "./bin/which" } }, "sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA=="], + + "global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + + "node-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "update-notifier/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "vite-plugin-web-extension/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "web-ext-run/strip-json-comments": ["strip-json-comments@5.0.2", "", {}, "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], "convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], @@ -745,6 +1141,16 @@ "convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + "cosmiconfig/parse-json/json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "cosmiconfig/parse-json/lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "fx-runner/which/isexe": ["isexe@1.1.2", "", {}, "sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw=="], + + "vite-plugin-web-extension/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index 4e3ec68..8ddd5c5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev": "bun run --cwd apps/dashboard dev", "build": "bun run --cwd apps/dashboard build", "lint": "bun run --cwd apps/dashboard lint", - "type-check": "bun run --cwd apps/dashboard type-check" + "type-check": "bun run --cwd apps/dashboard type-check", + "dev:extension": "bun run --cwd apps/extension dev", + "build:extension": "bun run --cwd apps/extension build" } } From e77eb90b16c032f3c5e83838708f739a7d723fce Mon Sep 17 00:00:00 2001 From: Annie Chen Date: Thu, 16 Apr 2026 20:39:47 -0400 Subject: [PATCH 02/43] extension frontend 70% --- apps/extension/manifest.json | 1 - apps/extension/src/App.tsx | 76 +++++++- .../extension/src/components/BookmarkView.tsx | 94 +++++++++ apps/extension/src/components/FeedView.tsx | 109 +++++++++++ .../src/components/FloatingPanel.tsx | 49 ++--- .../src/components/OriginalEmailView.tsx | 100 ++++++++++ .../extension/src/components/SearchHeader.tsx | 183 ++++++++++++++++++ apps/extension/src/components/SearchView.tsx | 144 ++++++++++++++ apps/extension/src/content.css | 19 +- apps/extension/src/content.tsx | 14 ++ apps/extension/vite.config.ts | 3 + shared/ui/src/assets/chevron-back.svg | 3 + shared/ui/src/assets/edit_icon.svg | 11 ++ shared/ui/src/components/Button.tsx | 2 +- .../components/Cards/ExtensionEventCard.tsx | 6 +- shared/ui/src/components/Tags.tsx | 2 +- shared/ui/src/styles/tokens.css | 7 +- 17 files changed, 778 insertions(+), 45 deletions(-) create mode 100644 shared/ui/src/assets/chevron-back.svg create mode 100644 shared/ui/src/assets/edit_icon.svg diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index df7792d..293f5d5 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -28,7 +28,6 @@ "https://calendar.google.com/*" ], "js": ["src/content.tsx"], - "css": ["src/content.css"], "run_at": "document_idle" } ], diff --git a/apps/extension/src/App.tsx b/apps/extension/src/App.tsx index 1fe85bd..4e4a788 100644 --- a/apps/extension/src/App.tsx +++ b/apps/extension/src/App.tsx @@ -1,7 +1,77 @@ -export default function App() { +import { useState } from 'react' +import { Button } from '@app/ui' +import SearchHeader from './components/SearchHeader' +import FeedView from './components/FeedView' +import BookmarkView from './components/BookmarkView' +import SearchView from './components/SearchView' +import OriginalEmailView from './components/OriginalEmailView' + +type View = 'feed' | 'bookmarks' | 'search' | 'email' + +export interface AppProps { + onClose?: () => void +} + +export default function App({ onClose }: AppProps) { + const [view, setView] = useState('feed') + const [activeTab, setActiveTab] = useState<'feed' | 'bookmarks'>('feed') + const [searchQuery, setSearchQuery] = useState('') + + const isSearchMode = view === 'search' || view === 'email' + + const handleTabChange = (tab: string) => { + const t = tab as 'feed' | 'bookmarks' + setActiveTab(t) + setView(t) + } + + const handleSearchFocus = () => setView('search') + + const handleSearchChange = (q: string) => { + setSearchQuery(q) + setView('search') + } + + const handleSearchClear = () => setSearchQuery('') + + const handleBack = () => { + setSearchQuery('') + setView(activeTab) + } + return ( -
-

Cornell Loop

+
+ {/* ── Sticky header ── */} +
+ +
+ + {/* ── Scrollable content (CTA scrolls with content, not sticky) ── */} +
+ {view === 'feed' && } + {view === 'bookmarks' && } + {view === 'search' && } + {view === 'email' && } + +
+ +
+
) } diff --git a/apps/extension/src/components/BookmarkView.tsx b/apps/extension/src/components/BookmarkView.tsx index e69de29..16de505 100644 --- a/apps/extension/src/components/BookmarkView.tsx +++ b/apps/extension/src/components/BookmarkView.tsx @@ -0,0 +1,94 @@ +import { Tag } from '@app/ui' +import { BookmarkCard } from './BookmarkCard' +import EditIcon from '@app/ui/assets/edit_icon.svg?react' + +// Figma: Inter Regular 16px, #5f5f5f, tracking -0.176px, leading 1.5 +const SORT_LABEL = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[1rem] leading-[1.5] tracking-[-0.176px] ' + + 'text-[#5f5f5f] whitespace-nowrap' + +// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5 +const SECTION_HEADING = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[1.25rem] leading-[1.5] tracking-[-0.22px] ' + + 'text-[#5f5f5f] whitespace-nowrap' + +const SORT_TAGS = ['Internships', 'Early career', 'Tech', 'Mentorship', 'Just for fun'] + +export default function BookmarkView() { + return ( +
+ + {/* ── Sort by ── */} +
+

Sort by

+ + {/* Figma: flex-wrap gap-[9px], tag chips + pencil icon at end */} +
+ {SORT_TAGS.map((label) => ( + {label} + ))} + + {/* "+" add tag chip */} + + + + {/* Pencil / edit icon — 16px, Figma node 528:5470 */} + +
+
+ + {/* ── Your Bookmarks ── */} +
+

Your Bookmarks

+ +
+ {/* Card 1 — RSVP + Add to Calendar */} + {}} + onAddToCalendar={() => {}} + /> + + {/* Card 2 — Add to Calendar only */} + {}} + /> + + {/* Card 3 — RSVP + Add to Calendar */} + {}} + onAddToCalendar={() => {}} + /> +
+
+ +
+ ) +} diff --git a/apps/extension/src/components/FeedView.tsx b/apps/extension/src/components/FeedView.tsx index e69de29..62b7b17 100644 --- a/apps/extension/src/components/FeedView.tsx +++ b/apps/extension/src/components/FeedView.tsx @@ -0,0 +1,109 @@ +import { ExtensionEventCard } from '@app/ui' + +// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5 +const SECTION_HEADING = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[1.25rem] leading-[1.5] tracking-[-0.22px] ' + + 'text-[#5f5f5f] whitespace-nowrap' + +export default function FeedView() { + return ( +
+ + {/* ── Your Subscriptions ── */} +
+

Your Subscriptions

+ + + + +
+ + {/* ── Trending This Week ── */} +
+

Trending This Week

+ + + + +
+ +
+ ) +} diff --git a/apps/extension/src/components/FloatingPanel.tsx b/apps/extension/src/components/FloatingPanel.tsx index 60b7408..1ee9757 100644 --- a/apps/extension/src/components/FloatingPanel.tsx +++ b/apps/extension/src/components/FloatingPanel.tsx @@ -1,50 +1,31 @@ import { useState } from 'react' import App from '../App' import FloatingIcon from '../../public/floating_icon.svg?react' -import CloseIcon from '@app/ui/assets/close_search.svg?react' export default function FloatingPanel() { const [isOpen, setIsOpen] = useState(false) return ( <> - {/* Floating tab — SVG is the full visual (bg, shape, shadow all included) */} - {!isOpen && ( - - )} - - {/* Side panel — slides in from the right */} -
setIsOpen(true)} className={[ - 'fixed top-0 right-0 h-full z-[9998]', - 'w-[var(--panel-width)] bg-background border-l border-border shadow-2xl', - 'flex flex-col transition-transform duration-300 ease-in-out', - isOpen ? 'translate-x-0' : 'translate-x-full', + 'fixed right-0 top-32 z-[9999] bg-transparent', + 'hover:brightness-95 transition-[filter,opacity] duration-300', + isOpen ? 'opacity-0 pointer-events-none' : 'opacity-100', ].join(' ')} + aria-label="Open Cornell Loop" > - {/* Panel header */} -
- Cornell Loop - -
+ + - {/* Panel content */} -
- -
+ {/* Panel outer shell — slides in/out via inline transform (reliable in shadow DOM) */} +
+ setIsOpen(false)} />
) diff --git a/apps/extension/src/components/OriginalEmailView.tsx b/apps/extension/src/components/OriginalEmailView.tsx index e69de29..edfbd0e 100644 --- a/apps/extension/src/components/OriginalEmailView.tsx +++ b/apps/extension/src/components/OriginalEmailView.tsx @@ -0,0 +1,100 @@ +import { Avatar } from '@app/ui' + +// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5 +const SECTION_HEADING = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[1.25rem] leading-[1.5] tracking-[-0.22px] ' + + 'text-[#5f5f5f] whitespace-nowrap' + +// Figma: DM Sans Bold 18px, #5f5f5f, lh 28px, tracking -0.5px +const EMAIL_TITLE = + 'font-[family-name:var(--font-body)] font-bold ' + + 'text-[length:var(--font-size-body1)] leading-[var(--line-height-body1)] ' + + 'tracking-[var(--letter-spacing-body1)] text-[#5f5f5f]' + +// Figma: DM Sans Regular 14px, #5f5f5f, lh 20.2px, tracking -0.5px +const EMAIL_BODY = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[length:var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)] text-[#5f5f5f]' + +// Email body split into paragraphs. Empty strings render as blank-line spacers. +const EMAIL_PARAGRAPHS = [ + 'Hello Eship,', + '', + 'For Cornell builders, aspiring VCs, and startup enthusiasts: Startup Hours is being held from 7:30–9pm Thursday, on the third floor of eHub Collegetown. Startup Hours is a student-run event supported by most of the entrepreneurship groups on campus where you can come by to work on your projects, meet with VCs and mentors for funding, find out about startup resources, and catch up with other builders and early-stage investors.', + '', + 'Startup Hours is hosted by Cornell Entrepreneurship Club, and is backed by Entrepreneurship at Cornell, along with VCs like Contrary Capital, Dorm Room Fund, .406 Ventures, Discipulus Ventures, and General Catalyst.', + '', + 'Please fill out this form for Dos Amigos catering by Wednesday 5pm, so we have a proper headcount.', + '', + 'Best,', + 'Alli', +] + +export interface OriginalEmailViewProps { + /** Organisation that sent the email. */ + orgName?: string + /** Email subject line shown as the card title. */ + emailTitle?: string + /** Paragraphs of the email body. Empty strings render as blank-line spacers. */ + paragraphs?: string[] +} + +export default function OriginalEmailView({ + orgName = 'Cornell DTI', + emailTitle = 'Datadog recruitment event', + paragraphs = EMAIL_PARAGRAPHS, +}: OriginalEmailViewProps) { + return ( +
+ + {/* "Original Email" heading — Inter SemiBold 20px */} +

Original Email

+ + {/* Email card — Figma: white bg, #ececec 1.5px border, rounded-[12px], p-[16px], gap-[20px] */} +
+ {/* Org header — avatar circle + org name */} + + + {/* Email content */} +
+ {/* Subject / title — DM Sans Bold 18px */} +

+ {emailTitle} +

+ + {/* Body paragraphs — empty strings become blank-line spacers */} +
+ {paragraphs.map((line, i) => + line === '' ? ( + +
+
+ +
+ ) +} diff --git a/apps/extension/src/components/SearchHeader.tsx b/apps/extension/src/components/SearchHeader.tsx index e69de29..c53c722 100644 --- a/apps/extension/src/components/SearchHeader.tsx +++ b/apps/extension/src/components/SearchHeader.tsx @@ -0,0 +1,183 @@ +/** + * SearchHeader — Extension panel header + * + * Two variants matching the Figma designs: + * + * 'main' (node 528:4032) — default view header + * Logo + Close | SearchBar | Toggle (Feed / Bookmarks) + * + * 'search' (node 554:7828) — active search / original-email view header + * Logo + Close | [← back] SearchBar (no toggle) + */ + +import type { ComponentPropsWithoutRef } from 'react' +import { SearchBar, Toggle, LoopLogo } from '@app/ui' +import CloseIcon from '@app/ui/assets/close_search.svg?react' +import ChevronBackIcon from '@app/ui/assets/chevron-back.svg?react' + +// ── Public types ─────────────────────────────────────────────────────────── + +export type SearchHeaderVariant = 'main' | 'search' + +export interface SearchHeaderProps + extends Omit, 'onChange'> { + /** + * Layout variant: + * 'main' → SearchBar + Feed/Bookmarks Toggle below logo row + * 'search' → Back chevron + SearchBar below logo row (no toggle) + * Defaults to 'main'. + */ + variant?: SearchHeaderVariant + + /** Called when the × close button is clicked. */ + onClose?: () => void + + /** Controlled search query string. */ + searchQuery?: string + /** Called with the new value whenever the search input changes. */ + onSearchChange?: (value: string) => void + /** Called when the × clear button inside the search bar is clicked. */ + onSearchClear?: () => void + /** Called when the search input receives focus (used to switch to search view). */ + onSearchFocus?: () => void + + /** + * Called when the ‹ back chevron is clicked. + * Only relevant in the 'search' variant. + */ + onBack?: () => void + + /** + * Currently active toggle tab value ('feed' | 'bookmarks'). + * Only relevant in the 'main' variant. Defaults to 'feed'. + */ + activeTab?: string + /** + * Called with the new tab value when a toggle option is selected. + * Only relevant in the 'main' variant. + */ + onTabChange?: (tab: string) => void +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const TOGGLE_OPTIONS = [ + { value: 'feed', label: 'Feed' }, + { value: 'bookmarks', label: 'Bookmarks' }, +] + +// Shared icon-button wrapper — 25.6 px hit target, subtle hover bg +const ICON_BTN = + 'shrink-0 flex items-center justify-center ' + + 'size-[var(--space-6)] rounded-[var(--radius-input)] cursor-pointer ' + + 'hover:bg-[var(--color-surface-subtle)] transition-colors duration-150 ' + + 'focus-visible:outline-2 focus-visible:outline-offset-1 ' + + 'focus-visible:outline-[var(--color-primary-700)]' + +// ── Component ────────────────────────────────────────────────────────────── + +export function SearchHeader({ + variant = 'main', + onClose, + searchQuery = '', + onSearchChange, + onSearchClear, + onSearchFocus, + onBack, + activeTab = 'feed', + onTabChange, + className, + ...rest +}: SearchHeaderProps) { + return ( +
+ {/* ── Logo row: wordmark left, close right ── */} +
+ {/* size="sm": 24px mark + 32px "Loop" wordmark — matches Figma panel header */} + + + +
+ + {/* ── Search row ── */} + {variant === 'main' ? ( + /* Main variant: search bar spans full width */ + + ) : ( + /* Search variant: back chevron + search bar (flex-1). + Figma gap between chevron and input: 4px (--space-1) */ +
+ + + +
+ )} + + {/* ── Toggle (main variant only) ── */} + {variant === 'main' && ( + {})} + size="compact" + className="w-full" + /> + )} +
+ ) +} + +export default SearchHeader diff --git a/apps/extension/src/components/SearchView.tsx b/apps/extension/src/components/SearchView.tsx index e69de29..9e16fe3 100644 --- a/apps/extension/src/components/SearchView.tsx +++ b/apps/extension/src/components/SearchView.tsx @@ -0,0 +1,144 @@ +import { Tag } from '@app/ui' +import { BookmarkCard } from './BookmarkCard' +import EditIcon from '@app/ui/assets/edit_icon.svg?react' + +// ── Shared typography ────────────────────────────────────────────────────── + +// Figma: Inter Regular 16px, #5f5f5f, tracking -0.176px, leading 1.5 +const UI_BODY = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[1rem] leading-[1.5] tracking-[-0.176px] text-[#5f5f5f]' + +// Figma: Inter Regular 16px label for "Sort by" (same style as UI_BODY) +const SORT_LABEL = UI_BODY + ' whitespace-nowrap' + +const SORT_TAGS = ['Internships', 'Early career', 'Tech', 'Mentorship', 'Just for fun'] + +const POPULAR_SEARCHES = [ + { rank: '#1', term: 'Recruitment' }, + { rank: '#2', term: 'Sports' }, + { rank: '#3', term: 'Concert' }, + { rank: '#4', term: 'Housing' }, + { rank: '#5', term: 'A&S' }, +] + +// ── SearchEmptyState ─────────────────────────────────────────────────────── +// Figma 554:7828 — shown when the search bar is focused but has no query. +// Displays "Popular searches this week" + 5 ranked trending rows. + +function SearchEmptyState() { + return ( +
+ {/* Heading — Figma: DM Sans Medium 18px, #5f5f5f, lh 28px, tracking -0.5px */} +

+ Popular searches this week +

+ + {/* Ranked rows — Figma: bg #f9f9f9, rounded-[16px], px-16 py-8, gap-12 */} +
+ {POPULAR_SEARCHES.map(({ rank, term }) => ( + + ))} +
+
+ ) +} + +// ── SearchResultsState ───────────────────────────────────────────────────── +// Figma 554:6324 — shown when there are search results. +// Displays "Sort by" tags + BookmarkCards. + +function SearchResultsState() { + return ( +
+ + {/* Sort by */} +
+

Sort by

+
+ {SORT_TAGS.map((label) => ( + {label} + ))} + + + +
+
+ + {/* Result cards */} +
+ {/* Card 1 — date, RSVP + Add to Calendar */} + {}} + onAddToCalendar={() => {}} + /> + + {/* Card 2 — news, single-line subtitle, Add to Calendar only */} + {}} + /> + + {/* Card 3 — date, no action buttons */} + +
+
+ ) +} + +// ── SearchView ───────────────────────────────────────────────────────────── +// Root export. Switches between the empty / popular state and the results +// state based on whether a query string is present. + +export interface SearchViewProps { + /** The current search query. Empty string or undefined → popular state. */ + query?: string +} + +export default function SearchView({ query = '' }: SearchViewProps) { + return query.trim() === '' ? : +} diff --git a/apps/extension/src/content.css b/apps/extension/src/content.css index 4f5474f..ab5277a 100644 --- a/apps/extension/src/content.css +++ b/apps/extension/src/content.css @@ -1,2 +1,19 @@ @import 'tailwindcss'; -@import '@app/ui/styles/tokens.css'; + +/* Explicitly scan component files so Tailwind generates all used utility classes */ +@source "../src/**/*.{ts,tsx}"; +@source "../../../shared/ui/src/**/*.{ts,tsx}"; + +@import '../../../shared/ui/src/styles/tokens.css'; + +/* + * Tailwind v4 `.border` uses `border-style: var(--tw-border-style)`. + * `@property --tw-border-style` does not apply inside ShadowRoot adoptedStyleSheets, + * so `--tw-border-style` is invalid and borders disappear (see tailwindcss#16025). + * Force solid borders after Tailwind utilities so width + color utilities still work. + */ +@layer utilities { + .border { + border-style: solid; + } +} diff --git a/apps/extension/src/content.tsx b/apps/extension/src/content.tsx index 73d874d..97da3e4 100644 --- a/apps/extension/src/content.tsx +++ b/apps/extension/src/content.tsx @@ -7,9 +7,21 @@ import contentStyles from './content.css?inline' const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string) +function loadFonts() { + if (document.getElementById('cornell-loop-fonts')) return + const link = document.createElement('link') + link.id = 'cornell-loop-fonts' + link.rel = 'stylesheet' + link.href = + 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Inter:wght@400;500;600;700&family=Manrope:wght@600;700&display=swap' + document.head.appendChild(link) +} + function mount() { if (document.getElementById('cornell-loop-host')) return + loadFonts() + const host = document.createElement('div') host.id = 'cornell-loop-host' // Explicit styles prevent Gmail/Calendar from accidentally hiding or @@ -23,6 +35,8 @@ function mount() { // Inject processed Tailwind + token styles into the shadow root so they // aren't blocked by shadow DOM style encapsulation. + console.log('[cornell-loop] styles length:', contentStyles.length) + const sheet = new CSSStyleSheet() sheet.replaceSync(contentStyles) shadow.adoptedStyleSheets = [sheet] diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index 8096df5..4e0fe2b 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ svgr(), webExtension({ manifest: './manifest.json' }), ], + build: { + cssCodeSplit: false, + }, resolve: { alias: { '@app/ui': path.resolve(__dirname, '../../shared/ui/src'), diff --git a/shared/ui/src/assets/chevron-back.svg b/shared/ui/src/assets/chevron-back.svg new file mode 100644 index 0000000..34e88ba --- /dev/null +++ b/shared/ui/src/assets/chevron-back.svg @@ -0,0 +1,3 @@ + + + diff --git a/shared/ui/src/assets/edit_icon.svg b/shared/ui/src/assets/edit_icon.svg new file mode 100644 index 0000000..3948447 --- /dev/null +++ b/shared/ui/src/assets/edit_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/shared/ui/src/components/Button.tsx b/shared/ui/src/components/Button.tsx index 31cef03..a14736c 100644 --- a/shared/ui/src/components/Button.tsx +++ b/shared/ui/src/components/Button.tsx @@ -75,7 +75,7 @@ const VARIANT_CLASSES: Record = { * Source: Figma Frame93 (node 383:415) */ secondary: - 'bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-black)] ' + + 'bg-[var(--color-surface)] border border-[var(--color-neutral-500)] text-[var(--color-neutral-700)] ' + 'hover:bg-[var(--color-surface-subtle)] ' + 'active:bg-[var(--color-border)] ' + 'focus-visible:outline-[var(--color-neutral-900)] ' + diff --git a/shared/ui/src/components/Cards/ExtensionEventCard.tsx b/shared/ui/src/components/Cards/ExtensionEventCard.tsx index 7aa723a..2e3b23b 100644 --- a/shared/ui/src/components/Cards/ExtensionEventCard.tsx +++ b/shared/ui/src/components/Cards/ExtensionEventCard.tsx @@ -49,12 +49,12 @@ export { DateBadge }; const BODY2_SEMIBOLD = 'font-[family-name:var(--font-body)] font-semibold ' + - 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'text-[length:var(--font-size-body2)] leading-[var(--line-height-body2)] ' + 'tracking-[var(--letter-spacing-body2)]'; const BODY3 = 'font-[family-name:var(--font-body)] font-normal ' + - 'text-[var(--font-size-body3)] leading-[var(--line-height-body3)] ' + + 'text-[length:var(--font-size-body3)] leading-[var(--line-height-body3)] ' + 'tracking-[var(--letter-spacing-body3)]'; // DateBadge, ThumbnailVariant, and DateBadgeProps are re-exported above @@ -266,7 +266,7 @@ export function ExtensionEventCard({ 'size-full flex items-center justify-center ' + 'bg-[var(--color-secondary-400)] ' + 'font-[family-name:var(--font-body)] font-semibold ' + - 'text-[var(--font-size-body3)] text-[var(--color-secondary-900)]' + 'text-[length:var(--font-size-body3)] text-[color:var(--color-secondary-900)]' } > {orgName.charAt(0).toUpperCase()} diff --git a/shared/ui/src/components/Tags.tsx b/shared/ui/src/components/Tags.tsx index 060bf36..c9a0a3c 100644 --- a/shared/ui/src/components/Tags.tsx +++ b/shared/ui/src/components/Tags.tsx @@ -54,7 +54,7 @@ const BASE_CLASSES = 'px-[var(--space-3)] py-[var(--space-0-5)] ' + 'rounded-[var(--radius-input)] ' + 'font-[family-name:var(--font-body)] font-medium ' + - 'text-[var(--font-size-body2)] leading-[var(--space-6)] ' + + 'text-[length:var(--font-size-body2)] leading-[var(--space-6)] ' + 'tracking-[var(--letter-spacing-body2)] ' + 'text-[var(--color-neutral-700)] ' + 'whitespace-nowrap select-none transition-colors duration-150'; diff --git a/shared/ui/src/styles/tokens.css b/shared/ui/src/styles/tokens.css index 2c6da58..dbd25e6 100644 --- a/shared/ui/src/styles/tokens.css +++ b/shared/ui/src/styles/tokens.css @@ -4,7 +4,7 @@ * Sections extracted: Colors, Typography, Effects (Shadows + Border Radius) */ -:root { +:root, :host { /* ───────────────────────────────────────────── * PRIMARY PALETTE (warm orange-brown brand) * ───────────────────────────────────────────── */ @@ -81,6 +81,7 @@ --font-heading: 'DM Sans', sans-serif; --font-body: 'DM Sans', sans-serif; --font-brand: 'Manrope', sans-serif; + /* --font-ui removed — all UI text uses DM Sans (--font-body) */ /* ───────────────────────────────────────────── * TYPOGRAPHY — FONT SIZES (px) @@ -176,6 +177,10 @@ --filter-icon-nav-selected: brightness(0) saturate(100%) invert(28%) sepia(84%) saturate(1180%) hue-rotate(8deg) brightness(99%) contrast(108%); + --filter-icon-close-default: + brightness(0) opacity(0.55); + --filter-icon-close-hover: + brightness(0) opacity(0.87); /* ───────────────────────────────────────────── * SPACING SCALE (4px base grid) From 756a450cc45885f7004eb24b0b64c11c2534189f Mon Sep 17 00:00:00 2001 From: Annie Chen Date: Thu, 16 Apr 2026 20:41:25 -0400 Subject: [PATCH 03/43] token change --- shared/ui/src/styles/tokens.css | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/ui/src/styles/tokens.css b/shared/ui/src/styles/tokens.css index dbd25e6..3b50d4e 100644 --- a/shared/ui/src/styles/tokens.css +++ b/shared/ui/src/styles/tokens.css @@ -75,7 +75,6 @@ /* ───────────────────────────────────────────── * TYPOGRAPHY — FONT FAMILIES * Primary typeface: DM Sans (headings + body copy) - * Secondary typeface: Inter (UI labels, metadata) * Brand wordmark: Manrope (sidebar logo) * ───────────────────────────────────────────── */ --font-heading: 'DM Sans', sans-serif; From 45dd9ca1ac5e1680bcf534b59913977ea6fd2041 Mon Sep 17 00:00:00 2001 From: NikhillA Date: Fri, 17 Apr 2026 22:25:51 -0400 Subject: [PATCH 04/43] dashboard updates --- shared/ui/src/components/Home.tsx | 473 ++++++++++++++++ shared/ui/src/components/Subscriptions.tsx | 607 +++++++++++++++++++++ 2 files changed, 1080 insertions(+) create mode 100644 shared/ui/src/components/Home.tsx create mode 100644 shared/ui/src/components/Subscriptions.tsx diff --git a/shared/ui/src/components/Home.tsx b/shared/ui/src/components/Home.tsx new file mode 100644 index 0000000..b3fdfe4 --- /dev/null +++ b/shared/ui/src/components/Home.tsx @@ -0,0 +1,473 @@ +/** + * Home — Loop Dashboard Page + * + * Source: Figma "Incubator-design-file" › node 263:3493 "Home" + * + * Full-page layout composed from existing design system components: + * • SideBar (left) — navigation rail with logo + primary nav + profile + * • Main feed (center) — Toggle tab switcher, tag filter bar, post list + * • Right panel — SearchBar, contextual event sections ("This week", "Trending") + * + * The feed uses the DashboardPost component for each post entry. + * The right-panel event rows mirror the SearchResultRow pattern from SearchPanel, + * extended to support circle avatars and the orange "For you" availability badge. + * + * "For you" badge: Figma uses bg #ffe4d5 / text #b54400 — approximated here with + * --color-primary-500 / --color-primary-800, the closest available palette tokens. + * + * All colours, spacing, and font values reference CSS custom properties from + * src/styles/tokens.css — nothing is hardcoded. + */ + +import type { ComponentPropsWithoutRef } from 'react'; +import { SideBar } from './SideBar'; +import type { SideBarItemId } from './SideBar'; +import { Toggle } from './Toggle'; +import { Tag } from './Tags'; +import { SearchBar } from './SearchBar'; +import { DashboardPost } from './Cards/DashboardPost'; +import type { DashboardPostProps } from './Cards/DashboardPost'; +import StarIcon from '../assets/star.svg?react'; + +// ─── Shared typography class strings ───────────────────────────────────────── + +const BODY2_SEMIBOLD = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const BODY2_REGULAR = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const SECTION_TITLE = + 'font-[family-name:var(--font-body)] font-bold ' + + 'text-[var(--font-size-sub2)] leading-[var(--line-height-sub2)] ' + + 'tracking-[var(--letter-spacing-body1)] ' + + 'text-[var(--color-neutral-900)] whitespace-nowrap'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export type FeedTab = 'for-you' | 'following'; + +export interface FeedTagItem { + /** Display label for the filter tag, e.g. "Recruitment". */ + label: string; +} + +/** + * A single event row rendered inside a right-panel section ("This week" / "Trending"). + * Mirrors the structure of Figma node 263:3698 and siblings. + */ +export interface HomeEventItem { + title: string; + orgName: string; + /** Optional avatar image URL. Falls back to an initial-letter badge. */ + orgAvatarUrl?: string; + /** + * When true a small neutral indicator badge (star icon) is shown beside the org name. + * Matches the Figma "Icon button" element (bg #949494 ≈ --color-neutral-600). + */ + hasIndicator?: boolean; + /** + * When true an orange "For you" badge is rendered to the right of the org row. + * Figma: bg #ffe4d5 / text #b54400 — approximated with --color-primary-500 / --color-primary-800. + */ + isForYou?: boolean; +} + +/** Props for a single right-panel section card ("This week" or "Trending"). */ +export interface HomeSidePanelProps { + /** Card heading text, e.g. "This week" or "Trending". */ + title: string; + items: HomeEventItem[]; + /** When provided a "Show more" link is rendered at the bottom of the card. */ + onShowMore?: () => void; +} + +export interface HomeProps extends ComponentPropsWithoutRef<'div'> { + // ── Sidebar ── + /** Currently active navigation item. Defaults to 'home'. */ + activeNavItem?: SideBarItemId; + /** Called with the nav item id when a sidebar tab is clicked. */ + onNavigate?: (id: SideBarItemId) => void; + + // ── Feed toggle ── + /** Active feed tab. Defaults to 'for-you'. */ + activeTab?: FeedTab; + /** Called with the new tab value when the toggle changes. */ + onTabChange?: (tab: FeedTab) => void; + + // ── Tag filter bar ── + /** + * List of tags shown in the horizontal filter bar. + * Defaults to the tags from the Figma spec. + */ + feedTags?: FeedTagItem[]; + /** Called with the tag label when a filter tag is clicked. */ + onTagClick?: (label: string) => void; + /** Called when the "+" add-tag button is clicked. */ + onAddTag?: () => void; + + // ── Posts ── + /** Feed posts rendered using the DashboardPost component. */ + posts?: DashboardPostProps[]; + + // ── Search ── + searchValue?: string; + onSearchChange?: (value: string) => void; + onSearchClear?: () => void; + + // ── Right panel sections ── + /** + * Ordered list of contextual event sections shown in the right panel. + * Figma shows "This week" first, then "Trending". + */ + sidePanels?: HomeSidePanelProps[]; +} + +// ─── Default data ───────────────────────────────────────────────────────────── + +const DEFAULT_FEED_TAGS: FeedTagItem[] = [ + { label: 'Recruitment' }, + { label: 'Early Career' }, + { label: 'Tech' }, + { label: 'Mentorship' }, + { label: 'Just for Fun' }, +]; + +// ─── HomeEventRow ───────────────────────────────────────────────────────────── + +/** + * A single event row inside a HomeSidePanel section. + * Renders: event title + org avatar + org name + optional indicator badge + optional "For you" tag. + * Matches Figma nodes 263:3698–263:3724. + */ +function HomeEventRow({ item }: { item: HomeEventItem }) { + return ( +
+ {/* Event title — semibold, single line truncation */} +

+ {item.title} +

+ + {/* Org row: avatar + name + indicator badge + optional "For you" tag */} +
+
+ {/* + * Circle avatar — 24 × 24 px (--space-6). + * Falls back to an initial-letter badge using secondary palette, + * consistent with the avatar pattern in DashboardPost. + */} + + {item.orgAvatarUrl ? ( + {item.orgName} + ) : ( + + {item.orgName.charAt(0).toUpperCase()} + + )} + + + {/* Org name */} + + {item.orgName} + + + {/* + * Indicator badge — small circular pill with a star icon. + * Figma bg: #949494 ≈ --color-neutral-600 (consistent with DashboardPost indicator). + */} + {item.hasIndicator && ( + + )} +
+ + {/* + * "For you" badge. + * Figma: bg #ffe4d5 / text #b54400 — approximated with + * --color-primary-500 (#ffcaaa) / --color-primary-800 (#a74409), + * the closest available palette tokens. + */} + {item.isForYou && ( + + For you + + )} +
+
+ ); +} + +// ─── HomeSidePanel ──────────────────────────────────────────────────────────── + +/** + * A contextual event section card in the right panel ("This week" / "Trending"). + * Matches Figma nodes 263:3695 and 263:3726. + * + * Container: bg white, Neutral/300 border, rounded-[16px], px 16px, py 12px. + * This mirrors the SearchResultList container style from SearchPanel.tsx. + */ +function HomeSidePanel({ title, items, onShowMore }: HomeSidePanelProps) { + return ( +
+ {/* Section title — DM Sans Bold 18px, Neutral/900 */} +

+ {title} +

+ + {/* Event rows */} +
+ {items.map((item, i) => ( + + ))} +
+ + {/* + * "Show more" link — Figma: Inter Regular 16px, #0074bc. + * --color-link maps to --color-secondary-600 (#427fb4), the closest token. + */} + {onShowMore && ( + + )} +
+ ); +} + +// ─── Home ───────────────────────────────────────────────────────────────────── + +/** + * Home page layout — three-column shell: + * + * ┌────────────┬──────────────────────────┬────────────────┐ + * │ SideBar │ Main feed │ Right panel │ + * │ (215px) │ (flex-1) │ (334px) │ + * │ │ Toggle + tag filter │ SearchBar │ + * │ Home │ ───────────────────── │ This week │ + * │ Bookmarks │ DashboardPost ×n │ Trending │ + * │ Subs │ │ │ + * │ ──────── │ │ │ + * │ Profile │ │ │ + * └────────────┴──────────────────────────┴────────────────┘ + */ +export function Home({ + activeNavItem = 'home', + onNavigate, + activeTab = 'for-you', + onTabChange, + feedTags = DEFAULT_FEED_TAGS, + onTagClick, + onAddTag, + posts = [], + searchValue, + onSearchChange, + onSearchClear, + sidePanels = [], + className, + ...rest +}: HomeProps) { + return ( +
+ {/* ── Left sidebar ── */} + + + {/* ── Main feed ── */} +
+ {/* ── Feed header: tab toggle + tag filter bar ── */} +
+ {/* + * Feed tab toggle — "For you" / "Following". + * w-full stretches the toggle to fill the padded container. + * Figma (node 263:3535): full-width pill container, px 24px. + */} +
+ onTabChange?.(v as FeedTab)} + className="w-full" + /> +
+ + {/* + * Tag filter bar — horizontal scrollable row of neutral Tag chips. + * Figma (node 263:3540): px 32px, gap 12px. + * The final "+" tag triggers onAddTag to open a tag picker. + */} +
+ {feedTags.map((tag) => ( + onTagClick?.(tag.label)} + className="cursor-pointer shrink-0" + style={{ fontVariationSettings: "'opsz' 14" }} + > + {tag.label} + + ))} + + {/* "+" tag — opens tag picker (Figma node 263:3552) */} + + + + +
+
+ + {/* Horizontal divider — Figma node 263:3557: 1px, --color-border */} +
+ + {/* ── Right panel ── */} + {/* + * Fixed-width aside — mirrors the SearchPanel layout from SearchPanel.tsx. + * Figma (node 263:3691): w-326px, px 24px, py 32px, gap 24px, left border. + * Uses --search-panel-width (334px) as the closest available layout token. + */} + +
+ ); +} diff --git a/shared/ui/src/components/Subscriptions.tsx b/shared/ui/src/components/Subscriptions.tsx new file mode 100644 index 0000000..c92674b --- /dev/null +++ b/shared/ui/src/components/Subscriptions.tsx @@ -0,0 +1,607 @@ +/** + * Subscriptions — Loop Dashboard Page + * + * Source: Figma "Incubator-design-file" › node 260:2755 "Home" (Subscriptions view) + * + * Three-column page layout: + * • SideBar (left) — navigation rail, "Subscriptions" active + * • Main content (center) — heading + count badge, SearchBar, subscription list + * • Right panel — SearchBar, "This week" + "Trending" event sections + * + * The subscription list renders one SubscriptionRow per org. Each row shows a + * circle avatar, org name, a "verified RSO" badge (with native tooltip), email + * stats, mailing-list address, and an "Unsubscribe" action button. + * + * "Unsubscribe" button bg: Figma uses #909090 — approximated with + * --color-neutral-600 (#616972), the closest available dark-neutral token. + * + * Avatar size: Figma specifies 60 × 60 px. --size-club-avatar (56 px) is the + * closest layout token; 60 px is used directly (size-[3.75rem]) since no token + * covers this exact value. + * + * "emails received" text: Figma 16 px — no exact token; uses --font-size-body2 + * (14 px) as the closest meta-text token, consistent with secondary-text usage + * across the design system. + * + * All colours, spacing, and font values reference CSS custom properties from + * src/styles/tokens.css — nothing is hardcoded. + */ + +import type { ComponentPropsWithoutRef } from 'react'; +import { SideBar } from './SideBar'; +import type { SideBarItemId } from './SideBar'; +import { SearchBar } from './SearchBar'; +import StarIcon from '../assets/star.svg?react'; + +// ─── Shared typography class strings ───────────────────────────────────────── + +const BODY2_SEMIBOLD = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const BODY2_REGULAR = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const SECTION_TITLE = + 'font-[family-name:var(--font-body)] font-bold ' + + 'text-[var(--font-size-sub2)] leading-[var(--line-height-sub2)] ' + + 'tracking-[var(--letter-spacing-body1)] ' + + 'text-[var(--color-neutral-900)] whitespace-nowrap'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** A single mailing-list subscription displayed in the main content list. */ +export interface SubscriptionItem { + orgName: string; + /** Optional avatar image URL. Falls back to an initial-letter badge. */ + orgAvatarUrl?: string; + /** + * When true, a "verified RSO" indicator badge is shown beside the org name. + * Figma annotation: "Hover to show : this is a registered student organization + * at Cornell". Rendered with a native tooltip via the `title` attribute. + */ + isVerified?: boolean; + /** Total emails received from this list; shown as semibold count + regular label. */ + emailsReceived: number; + /** Mailing-list address displayed in italic below the stats row. */ + emailAddress: string; + /** Called when the "Unsubscribe" button is clicked. */ + onUnsubscribe?: () => void; +} + +/** + * A single event row inside a right-panel section ("This week" / "Trending"). + * Mirrors the HomeEventItem pattern from Home.tsx. + */ +export interface SideEventItem { + title: string; + orgName: string; + orgAvatarUrl?: string; + /** + * When true a small indicator badge (star icon) is shown beside the org name. + * Figma bg: #949494 ≈ --color-neutral-600. + */ + hasIndicator?: boolean; + /** + * When true an orange "For you" badge is rendered to the right of the org row. + * Figma: bg #ffe4d5 / text #b54400 — approximated with + * --color-primary-500 / --color-primary-800. + */ + isForYou?: boolean; +} + +/** Props for a right-panel contextual section card ("This week" / "Trending"). */ +export interface SidePanelSectionProps { + title: string; + items: SideEventItem[]; + onShowMore?: () => void; +} + +export interface SubscriptionsProps extends ComponentPropsWithoutRef<'div'> { + // ── Sidebar ── + activeNavItem?: SideBarItemId; + onNavigate?: (id: SideBarItemId) => void; + + // ── Header ── + /** Total subscription count shown in the badge beside the heading. */ + subscriptionCount?: number; + + // ── Center search ── + searchValue?: string; + onSearchChange?: (value: string) => void; + onSearchClear?: () => void; + + // ── Subscription list ── + subscriptions?: SubscriptionItem[]; + + // ── Right panel search ── + sidePanelSearchValue?: string; + onSidePanelSearchChange?: (value: string) => void; + onSidePanelSearchClear?: () => void; + + // ── Right panel sections ── + sidePanels?: SidePanelSectionProps[]; +} + +// ─── SubscriptionRow ────────────────────────────────────────────────────────── + +/** + * A single row in the subscriptions list. + * Matches Figma node 260:2988 and siblings. + * + * Container: bg white, Neutral/300 border, rounded-[16px], px 16px, py 12px. + */ +function SubscriptionRow({ item }: { item: SubscriptionItem }) { + return ( +
+ {/* ── Left: avatar + org info ── */} +
+ {/* + * Circle avatar — 60 × 60 px (size-[3.75rem]). + * Falls back to an initial-letter badge using the secondary palette, + * consistent with the avatar pattern in DashboardPost and SearchPanel. + */} + + {item.orgAvatarUrl ? ( + {item.orgName} + ) : ( + + {item.orgName.charAt(0).toUpperCase()} + + )} + + + {/* Org info stack */} +
+ {/* Org name row: name + optional verified badge */} +
+ + {item.orgName} + + + {/* + * Verified RSO badge — small circular indicator with a star icon. + * Figma annotation: "Hover to show : this is a registered student + * organization at Cornell". Native `title` tooltip fulfils this. + * Bg: #949494 ≈ --color-neutral-600 (consistent with DashboardPost indicator). + */} + {item.isVerified && ( + + + )} +
+ + {/* + * Emails received — semibold count inline with regular label. + * Figma: "43 emails received" — count is SemiBold, rest is Regular. + * Text colour: #949494 ≈ --color-text-muted (Neutral/500 #adb5bd), + * the closest available muted-text token. + */} +

+ {item.emailsReceived} + {' emails received'} +

+ + {/* Mailing-list address — italic, muted */} +

+ {item.emailAddress} +

+
+
+ + {/* + * Unsubscribe button. + * Figma: bg #909090, px 16px, py 8px, rounded-[16px], white SemiBold text. + * --color-neutral-600 (#616972) is the closest dark-neutral token; + * exact Figma value #909090 lies between neutral-500 and neutral-600. + */} + +
+ ); +} + +// ─── SideEventRow ───────────────────────────────────────────────────────────── + +/** + * A single event row inside a right-panel section. + * Matches Figma nodes 260:2925–260:2951 and 260:2956–260:2980. + */ +function SideEventRow({ item }: { item: SideEventItem }) { + return ( +
+ {/* Event title */} +

+ {item.title} +

+ + {/* Org row: avatar + name + optional indicator + optional "For you" tag */} +
+
+ {/* Circle avatar — 24 × 24 px */} + + {item.orgAvatarUrl ? ( + {item.orgName} + ) : ( + + {item.orgName.charAt(0).toUpperCase()} + + )} + + + {/* Org name */} + + {item.orgName} + + + {/* + * Indicator badge — small circular pill with a star icon. + * Figma bg: #949494 ≈ --color-neutral-600. + */} + {item.hasIndicator && ( + + )} +
+ + {/* + * "For you" badge. + * Figma: bg #ffe4d5 / text #b54400 — approximated with + * --color-primary-500 (#ffcaaa) / --color-primary-800 (#a74409). + */} + {item.isForYou && ( + + For you + + )} +
+
+ ); +} + +// ─── SidePanelSection ───────────────────────────────────────────────────────── + +/** + * A contextual event section card in the right panel ("This week" / "Trending"). + * Matches Figma nodes 260:2922 and 260:2953. + */ +function SidePanelSection({ title, items, onShowMore }: SidePanelSectionProps) { + return ( +
+

+ {title} +

+ +
+ {items.map((item, i) => ( + + ))} +
+ + {/* + * "Show more" link — Figma: #0074bc. + * --color-link maps to --color-secondary-600 (#427fb4), the closest token. + */} + {onShowMore && ( + + )} +
+ ); +} + +// ─── Subscriptions ──────────────────────────────────────────────────────────── + +/** + * Subscriptions page layout — three-column shell: + * + * ┌────────────┬────────────────────────────────┬────────────────┐ + * │ SideBar │ Main content │ Right panel │ + * │ (215px) │ (flex-1) │ (334px) │ + * │ │ "Subscriptions" heading │ SearchBar │ + * │ Home │ + count badge │ This week │ + * │ Bookmarks │ SearchBar │ Trending │ + * │ Subs ● │ ───────────────────────── │ │ + * │ ──────── │ SubscriptionRow × n │ │ + * │ Profile │ │ │ + * └────────────┴────────────────────────────────┴────────────────┘ + */ +export function Subscriptions({ + activeNavItem = 'subscriptions', + onNavigate, + subscriptionCount, + searchValue, + onSearchChange, + onSearchClear, + subscriptions = [], + sidePanelSearchValue, + onSidePanelSearchChange, + onSidePanelSearchClear, + sidePanels = [], + className, + ...rest +}: SubscriptionsProps) { + return ( +
+ {/* ── Left sidebar ── */} + + + {/* ── Main content ── */} +
+ {/* ── Page header ── */} +
+ {/* + * Heading row: "Subscriptions" wordmark + count badge. + * Figma (node 260:2796): px 24px, gap 12px. + * Typography: Manrope Bold ~31px = --font-brand + --font-size-wordmark. + */} +
+

+ Subscriptions +

+ + {/* + * Count badge — Figma (node 260:3022): bg #ececec ≈ --color-neutral-200, + * text #767676 ≈ --color-text-secondary, rounded-[12px]. + * --space-3 (12px) is used for the border-radius as it has no exact token. + */} + {subscriptionCount !== undefined && ( + + {subscriptionCount} + + )} +
+ + {/* + * Center SearchBar — Figma (node 260:2800): full-width, px 32px. + */} +
+ +
+
+ + {/* Horizontal divider — Figma node 260:2814 */} +
+ + {/* ── Right panel ── */} + {/* + * Mirrors the right-panel layout from the Home page. + * Figma (node 260:2918): w-326px, px 24px, py 32px, gap 24px, left border. + * Uses --search-panel-width (334px) as the closest layout token. + */} + +
+ ); +} From 4894bcf3f5b45c98f3a6902ed6ab1f6f734e6821 Mon Sep 17 00:00:00 2001 From: NikhillA Date: Tue, 21 Apr 2026 22:15:02 -0400 Subject: [PATCH 05/43] Dashboard --- apps/dashboard/src/pages/Bookmarks.tsx | 455 ++++++++++++++++++ .../dashboard/src/pages}/Home.tsx | 14 +- .../dashboard/src/pages}/Subscriptions.tsx | 6 +- shared/ui/tsconfig.json | 2 +- 4 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 apps/dashboard/src/pages/Bookmarks.tsx rename {shared/ui/src/components => apps/dashboard/src/pages}/Home.tsx (96%) rename {shared/ui/src/components => apps/dashboard/src/pages}/Subscriptions.tsx (99%) diff --git a/apps/dashboard/src/pages/Bookmarks.tsx b/apps/dashboard/src/pages/Bookmarks.tsx new file mode 100644 index 0000000..12c6c19 --- /dev/null +++ b/apps/dashboard/src/pages/Bookmarks.tsx @@ -0,0 +1,455 @@ +/** + * Bookmarks — Loop Dashboard Page + * + * Source: Figma "Incubator-design-file" › node 260:2325 "Home" (Bookmarks view) + * + * Three-column page layout: + * • SideBar (left) — navigation rail, "Bookmarks" active + * • Main content (center) — heading, SearchBar, tag filter bar, bookmarked post list + * • Right panel — SearchBar, "This week" + "Trending" event sections + * + * The main content renders one DashboardPost per bookmarked item, each with + * `bookmarked={true}` to show the filled bookmark icon state. + * + * The tag filter bar mirrors the Home page filter bar (Recruitment, Early Career, etc.) + * and sits directly below the SearchBar in a shared px-32px column. + * + * Right-panel sections (SideEventRow, SidePanelSection) mirror the pattern + * from Subscriptions.tsx. + * + * All colours, spacing, and font values reference CSS custom properties from + * src/styles/tokens.css — nothing is hardcoded. + */ + +import type { ComponentPropsWithoutRef } from 'react'; +import { SideBar } from '@app/ui'; +import type { SideBarItemId } from '@app/ui'; +import { SearchBar } from '@app/ui'; +import { Tag } from '@app/ui'; +import { DashboardPost } from '@app/ui'; +import type { DashboardPostProps } from '@app/ui'; +import StarIcon from '../assets/star.svg?react'; + +// ─── Shared typography class strings ───────────────────────────────────────── + +const BODY2_SEMIBOLD = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const BODY2_REGULAR = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const SECTION_TITLE = + 'font-[family-name:var(--font-body)] font-bold ' + + 'text-[var(--font-size-sub2)] leading-[var(--line-height-sub2)] ' + + 'tracking-[var(--letter-spacing-body1)] ' + + 'text-[var(--color-neutral-900)] whitespace-nowrap'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** A single tag in the filter bar, e.g. "Recruitment". */ +export interface FeedTagItem { + label: string; +} + +/** + * A single event row inside a right-panel section ("This week" / "Trending"). + * Mirrors the SideEventItem pattern from Subscriptions.tsx. + */ +export interface SideEventItem { + title: string; + orgName: string; + orgAvatarUrl?: string; + /** + * When true a small indicator badge (star icon) is shown beside the org name. + * Figma bg: #949494 ≈ --color-neutral-600. + */ + hasIndicator?: boolean; + /** + * When true an orange "For you" badge is rendered to the right of the org row. + * Figma: bg #ffe4d5 / text #b54400 — approximated with + * --color-primary-500 / --color-primary-800. + */ + isForYou?: boolean; +} + +/** Props for a right-panel contextual section card ("This week" / "Trending"). */ +export interface SidePanelSectionProps { + title: string; + items: SideEventItem[]; + onShowMore?: () => void; +} + +export interface BookmarksProps extends ComponentPropsWithoutRef<'div'> { + // ── Sidebar ── + activeNavItem?: SideBarItemId; + onNavigate?: (id: SideBarItemId) => void; + + // ── Center search ── + searchValue?: string; + onSearchChange?: (value: string) => void; + onSearchClear?: () => void; + + // ── Tag filter bar ── + feedTags?: FeedTagItem[]; + onTagClick?: (label: string) => void; + onAddTag?: () => void; + + // ── Bookmarked posts ── + posts?: DashboardPostProps[]; + + // ── Right panel search ── + sidePanelSearchValue?: string; + onSidePanelSearchChange?: (value: string) => void; + onSidePanelSearchClear?: () => void; + + // ── Right panel sections ── + sidePanels?: SidePanelSectionProps[]; +} + +// ─── Default data ───────────────────────────────────────────────────────────── + +const DEFAULT_FEED_TAGS: FeedTagItem[] = [ + { label: 'Recruitment' }, + { label: 'Early Career' }, + { label: 'Tech' }, + { label: 'Mentorship' }, + { label: 'Just for Fun' }, +]; + +// ─── SideEventRow ───────────────────────────────────────────────────────────── + +/** + * A single event row inside a right-panel section. + * Matches Figma nodes 260:2513–260:2539 and 260:2544–260:2568. + */ +function SideEventRow({ item }: { item: SideEventItem }) { + return ( +
+ {/* Event title */} +

+ {item.title} +

+ + {/* Org row: avatar + name + optional indicator + optional "For you" tag */} +
+
+ {/* Circle avatar — 24 × 24 px */} + + {item.orgAvatarUrl ? ( + {item.orgName} + ) : ( + + {item.orgName.charAt(0).toUpperCase()} + + )} + + + {/* Org name */} + + {item.orgName} + + + {/* + * Indicator badge — small circular pill with a star icon. + * Figma bg: #949494 ≈ --color-neutral-600. + */} + {item.hasIndicator && ( + + )} +
+ + {/* + * "For you" badge. + * Figma: bg #ffe4d5 / text #b54400 — approximated with + * --color-primary-500 (#ffcaaa) / --color-primary-800 (#a74409). + */} + {item.isForYou && ( + + For you + + )} +
+
+ ); +} + +// ─── SidePanelSection ───────────────────────────────────────────────────────── + +/** + * A contextual event section card in the right panel ("This week" / "Trending"). + * Matches Figma nodes 260:2510 and 260:2541. + */ +function SidePanelSection({ title, items, onShowMore }: SidePanelSectionProps) { + return ( +
+

+ {title} +

+ +
+ {items.map((item, i) => ( + + ))} +
+ + {/* + * "Show more" link — Figma: #0074bc. + * --color-link maps to --color-secondary-600 (#427fb4), the closest token. + */} + {onShowMore && ( + + )} +
+ ); +} + +// ─── Bookmarks ──────────────────────────────────────────────────────────────── + +/** + * Bookmarks page layout — three-column shell: + * + * ┌────────────┬────────────────────────────────┬────────────────┐ + * │ SideBar │ Main content │ Right panel │ + * │ (215px) │ (flex-1) │ (334px) │ + * │ │ "Bookmarks" heading │ SearchBar │ + * │ Home │ SearchBar + tag filter bar │ This week │ + * │ Bookmarks ● ────────────────────────── │ Trending │ + * │ Subs │ DashboardPost (bookmarked) × n │ │ + * │ ──────── │ │ │ + * │ Profile │ │ │ + * └────────────┴────────────────────────────────┴────────────────┘ + */ +export function Bookmarks({ + activeNavItem = 'bookmarks', + onNavigate, + searchValue, + onSearchChange, + onSearchClear, + feedTags = DEFAULT_FEED_TAGS, + onTagClick, + onAddTag, + posts = [], + sidePanelSearchValue, + onSidePanelSearchChange, + onSidePanelSearchClear, + sidePanels = [], + className, + ...rest +}: BookmarksProps) { + return ( +
+ {/* ── Left sidebar ── */} + + + {/* ── Main content ── */} +
+ {/* ── Page header ── */} +
+ {/* + * Heading — Figma (node 260:2577): px 24px. + * Typography: Manrope Bold ~31px = --font-brand + --font-size-wordmark. + */} +
+

+ Bookmarks +

+
+ + {/* + * SearchBar + tag filter — Figma (node 260:2372): px 32px, stacked vertically. + */} +
+ + + {/* + * Tag filter bar — Figma (node 260:2593): horizontal row of neutral Tag chips. + * The final "+" tag triggers onAddTag to open a tag picker. + */} +
+ {feedTags.map((tag) => ( + onTagClick?.(tag.label)} + className="cursor-pointer shrink-0" + style={{ fontVariationSettings: "'opsz' 14" }} + > + {tag.label} + + ))} + + {/* "+" tag — opens tag picker */} + + + + +
+
+
+ + {/* Horizontal divider — Figma node 260:2389 */} +
+ + {/* ── Right panel ── */} + {/* + * Mirrors the right-panel layout from Home and Subscriptions pages. + * Figma (node 260:2506): w-326px, px 24px, py 32px, gap 24px, left border. + * Uses --search-panel-width (334px) as the closest layout token. + */} + +
+ ); +} diff --git a/shared/ui/src/components/Home.tsx b/apps/dashboard/src/pages/Home.tsx similarity index 96% rename from shared/ui/src/components/Home.tsx rename to apps/dashboard/src/pages/Home.tsx index b3fdfe4..e793e80 100644 --- a/shared/ui/src/components/Home.tsx +++ b/apps/dashboard/src/pages/Home.tsx @@ -20,13 +20,13 @@ */ import type { ComponentPropsWithoutRef } from 'react'; -import { SideBar } from './SideBar'; -import type { SideBarItemId } from './SideBar'; -import { Toggle } from './Toggle'; -import { Tag } from './Tags'; -import { SearchBar } from './SearchBar'; -import { DashboardPost } from './Cards/DashboardPost'; -import type { DashboardPostProps } from './Cards/DashboardPost'; +import { SideBar } from '../../../../shared/ui/src/components/SideBar'; +import type { SideBarItemId } from '../../../../shared/ui/src/components/SideBar'; +import { Toggle } from '../../../../shared/ui/src/components/Toggle'; +import { Tag } from '../../../../shared/ui/src/components/Tags'; +import { SearchBar } from '../../../../shared/ui/src/components/SearchBar'; +import { DashboardPost } from '../../../../shared/ui/src/components/Cards/DashboardPost'; +import type { DashboardPostProps } from '../../../../shared/ui/src/components/Cards/DashboardPost'; import StarIcon from '../assets/star.svg?react'; // ─── Shared typography class strings ───────────────────────────────────────── diff --git a/shared/ui/src/components/Subscriptions.tsx b/apps/dashboard/src/pages/Subscriptions.tsx similarity index 99% rename from shared/ui/src/components/Subscriptions.tsx rename to apps/dashboard/src/pages/Subscriptions.tsx index c92674b..f4c89d2 100644 --- a/shared/ui/src/components/Subscriptions.tsx +++ b/apps/dashboard/src/pages/Subscriptions.tsx @@ -28,9 +28,9 @@ */ import type { ComponentPropsWithoutRef } from 'react'; -import { SideBar } from './SideBar'; -import type { SideBarItemId } from './SideBar'; -import { SearchBar } from './SearchBar'; +import { SideBar } from '@app/ui'; +import type { SideBarItemId } from '@app/ui'; +import { SearchBar } from '@app/ui'; import StarIcon from '../assets/star.svg?react'; // ─── Shared typography class strings ───────────────────────────────────────── diff --git a/shared/ui/tsconfig.json b/shared/ui/tsconfig.json index 7ed7088..9ab0166 100644 --- a/shared/ui/tsconfig.json +++ b/shared/ui/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite-plugin-svgr/client"], + "types": ["vite-plugin-svgr/client", "react"], "skipLibCheck": true, /* Bundler mode */ From a49129351f34fb538cd9720bc05cfdc0ae353938 Mon Sep 17 00:00:00 2001 From: Annie Chen Date: Wed, 22 Apr 2026 00:53:01 -0400 Subject: [PATCH 06/43] prettier fixes pt2 --- .env | 8 ++++++++ ai/data/dummy_data.json | 2 +- ai/prompts/clubSummary.ts | 4 ++-- ai/prompts/weeklySummary.ts | 4 ++-- ai/summarize.ts | 12 ++++++------ ai/test.ts | 34 ++++++++++++++++++---------------- convex/schema.ts | 8 ++++---- 7 files changed, 41 insertions(+), 31 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..d2b04fc --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +# Deployment used by `npx convex dev` +CONVEX_DEPLOYMENT=dev:laudable-butterfly-859 # team: slope, project: slope + +VITE_CONVEX_URL=https://laudable-butterfly-859.convex.cloud + +VITE_CONVEX_SITE_URL=https://laudable-butterfly-859.convex.site + +GEMINI_API_KEY= diff --git a/ai/data/dummy_data.json b/ai/data/dummy_data.json index cf25c77..a5dc2ca 100644 --- a/ai/data/dummy_data.json +++ b/ai/data/dummy_data.json @@ -89,4 +89,4 @@ "location": "New York City", "tags": ["program", "first-year", "professional-track"] } -] \ No newline at end of file +] diff --git a/ai/prompts/clubSummary.ts b/ai/prompts/clubSummary.ts index 81ce705..44e6f93 100644 --- a/ai/prompts/clubSummary.ts +++ b/ai/prompts/clubSummary.ts @@ -22,5 +22,5 @@ Here are the events for this club (JSON): \`\`\`json ${JSON.stringify(events, null, 2)} \`\`\` -` -} \ No newline at end of file +`; +} diff --git a/ai/prompts/weeklySummary.ts b/ai/prompts/weeklySummary.ts index f5cebe0..fd77a29 100644 --- a/ai/prompts/weeklySummary.ts +++ b/ai/prompts/weeklySummary.ts @@ -44,5 +44,5 @@ Now write the digest based on this JSON: \`\`\`json ${JSON.stringify(events, null, 2)} \`\`\` -` -} \ No newline at end of file +`; +} diff --git a/ai/summarize.ts b/ai/summarize.ts index e4fcc39..19059a5 100644 --- a/ai/summarize.ts +++ b/ai/summarize.ts @@ -1,14 +1,14 @@ import { GoogleGenAI } from "@google/genai"; const ai = new GoogleGenAI({ - apiKey: process.env.GEMINI_API_KEY, -}) + apiKey: process.env.GEMINI_API_KEY, +}); export async function generateSummary(prompt: string) { const res = await ai.models.generateContent({ model: "gemini-2.5-flash", - contents: prompt - }) + contents: prompt, + }); - return res.text -} \ No newline at end of file + return res.text; +} diff --git a/ai/test.ts b/ai/test.ts index 0058558..b564aa8 100644 --- a/ai/test.ts +++ b/ai/test.ts @@ -1,27 +1,29 @@ -import data from "./data/dummy_data.json" -import { generateSummary } from "./summarize" -import { weeklySummaryPrompt } from "./prompts/weeklySummary" -import { clubSummaryPrompt } from "./prompts/clubSummary" +import data from "./data/dummy_data.json"; +import { generateSummary } from "./summarize"; +import { weeklySummaryPrompt } from "./prompts/weeklySummary"; +import { clubSummaryPrompt } from "./prompts/clubSummary"; async function run() { // Example: a user subscribed to ACSU and WICC listservs - const subscribedListservs = ["ACSU", "WICC"] - const weeklyEvents = data.filter(e => subscribedListservs.includes(e.listserv)) + const subscribedListservs = ["ACSU", "WICC"]; + const weeklyEvents = data.filter((e) => + subscribedListservs.includes(e.listserv), + ); - const weeklyPrompt = weeklySummaryPrompt(weeklyEvents) - const weeklySummary = await generateSummary(weeklyPrompt) + const weeklyPrompt = weeklySummaryPrompt(weeklyEvents); + const weeklySummary = await generateSummary(weeklyPrompt); - console.log("\nWEEKLY SUMMARY\n") - console.log(weeklySummary) + console.log("\nWEEKLY SUMMARY\n"); + console.log(weeklySummary); // Example: summarize what ACSU does based on its hosted events - const acsuEvents = data.filter(e => e.club === "ACSU") + const acsuEvents = data.filter((e) => e.club === "ACSU"); - const clubPrompt = clubSummaryPrompt(acsuEvents) - const clubSummary = await generateSummary(clubPrompt) + const clubPrompt = clubSummaryPrompt(acsuEvents); + const clubSummary = await generateSummary(clubPrompt); - console.log("\nACSU CLUB SUMMARY\n") - console.log(clubSummary) + console.log("\nACSU CLUB SUMMARY\n"); + console.log(clubSummary); } -run() \ No newline at end of file +run(); diff --git a/convex/schema.ts b/convex/schema.ts index 02fdc7a..7caca4f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -67,7 +67,7 @@ export default defineSchema({ location: v.optional( v.object({ - displayText: v.string(), + displayText: v.string(), address: v.optional(v.string()), isVirtual: v.boolean(), buildingCode: v.optional(v.string()), // "Gates 114", "CIS 250" @@ -81,7 +81,7 @@ export default defineSchema({ v.literal("registration"), // more for courses, hackathons, etc. v.literal("application"), // internships, jobs, programs v.literal("rsvp"), // events with physical link (decide if combines with registration) - v.literal("info"), // general info, websites, etc. + v.literal("info"), // general info, websites, etc. v.literal("social"), // instagram, etc. ), label: v.optional(v.string()), // "Apply here", "RSVP Link" @@ -101,7 +101,8 @@ export default defineSchema({ tags: v.array(v.string()), targetAudience: v.optional( - v.union( // decide if we need all or define more + v.union( + // decide if we need all or define more v.literal("all"), v.literal("first_year"), v.literal("women_nonbinary"), @@ -118,7 +119,6 @@ export default defineSchema({ v.literal("paid"), ), ), - }) .index("by_listserv", ["listserv"]) .index("by_section", ["listservSection"]) From a085691a71a3b868281b98c8e62becf168159062 Mon Sep 17 00:00:00 2001 From: Annie Chen Date: Thu, 23 Apr 2026 18:30:27 -0400 Subject: [PATCH 07/43] Merge remote-tracking branch 'origin/annie/extension' into annie/extension From 3781b5c704400831f4ed2ca6a197809cff38f479 Mon Sep 17 00:00:00 2001 From: NikhillA Date: Thu, 23 Apr 2026 18:43:34 -0400 Subject: [PATCH 08/43] new pages --- apps/dashboard/src/pages/Org.tsx | 791 +++++++++++++++++++++++++++ apps/dashboard/src/pages/profile.tsx | 352 ++++++++++++ 2 files changed, 1143 insertions(+) create mode 100644 apps/dashboard/src/pages/Org.tsx create mode 100644 apps/dashboard/src/pages/profile.tsx diff --git a/apps/dashboard/src/pages/Org.tsx b/apps/dashboard/src/pages/Org.tsx new file mode 100644 index 0000000..f4e94c9 --- /dev/null +++ b/apps/dashboard/src/pages/Org.tsx @@ -0,0 +1,791 @@ +/** + * Org — Loop Dashboard Page + * + * Source: Figma "Incubator-design-file" › node 1:573 "club page" + * + * Three-column page layout: + * • SideBar (left) — navigation rail, any item active + * • Main content (center) — cover banner, org avatar, action buttons, org info, + * Loop Summary card, posts feed with search/filter bar + * • Right panel — SearchBar, "This week" + "Trending" event sections + * + * Cover banner: 240 px tall full-width area; pass `coverImageUrl` for a real image. + * Org avatar: 121 × 121 px circle, overlaps the cover bottom at left = 24 px. + * Figma: top = 179 px = 11.1875 rem within the relative cover/action wrapper. + * + * Verified badge: Figma annotation "Hover to show : this is a registered student + * organization at Cornell". Rendered as a small rounded indicator with a native tooltip. + * + * Follow button bg: Figma #909090 — approximated with --color-neutral-600 (#616972). + * "For you" org tag: Figma bg #ffe4d5 / text #b54400 — approximated with + * --color-primary-500 (#ffcaaa) / --color-primary-800 (#a74409). + * Org name font size: Figma 22 px — used directly (1.375 rem) as no token covers + * this exact value, matching the precedent in Subscriptions.tsx (size-[3.75rem]). + * + * All colours, spacing, and font values reference CSS custom properties from + * src/styles/tokens.css — nothing is hardcoded except where noted above. + */ + +import type { ComponentPropsWithoutRef } from 'react'; +import { SideBar } from '@app/ui'; +import type { SideBarItemId } from '@app/ui'; +import { SearchBar } from '@app/ui'; +import { LoopSummary } from '@app/ui'; +import { DashboardPost } from '@app/ui'; +import type { DashboardPostProps } from '@app/ui'; +import { Tag } from '@app/ui'; +import StarIcon from '../assets/star.svg?react'; + +// ─── Inline icon helpers ────────────────────────────────────────────────────── +// Globe and Mail icons are not in shared/ui/src/assets; defined inline here. + +function GlobeIcon({ className }: { className?: string }) { + return ( + + ); +} + +function MailIcon({ className }: { className?: string }) { + return ( + + ); +} + +function ChevronDownIcon({ className }: { className?: string }) { + return ( + + ); +} + +// ─── Shared typography class strings ───────────────────────────────────────── + +const BODY2_SEMIBOLD = + 'font-[family-name:var(--font-body)] font-semibold ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const BODY2_REGULAR = + 'font-[family-name:var(--font-body)] font-normal ' + + 'text-[var(--font-size-body2)] leading-[var(--line-height-body2)] ' + + 'tracking-[var(--letter-spacing-body2)]'; + +const SECTION_TITLE = + 'font-[family-name:var(--font-body)] font-bold ' + + 'text-[var(--font-size-sub2)] leading-[var(--line-height-sub2)] ' + + 'tracking-[var(--letter-spacing-body1)] ' + + 'text-[var(--color-neutral-900)] whitespace-nowrap'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export interface OrgTag { + label: string; + /** + * 'primary' renders an orange "For you" style pill + * (Figma: bg #ffe4d5 / text #b54400 ≈ --color-primary-500 / --color-primary-800). + * 'neutral' renders a gray category pill (Tag component neutral variant). + * Defaults to 'neutral'. + */ + variant?: 'primary' | 'neutral'; +} + +/** A single event row rendered inside an OrgSidePanel section. */ +export interface OrgSideEventItem { + title: string; + orgName: string; + orgAvatarUrl?: string; + /** + * When true a small indicator badge (star icon) is shown beside the org name. + * Figma bg: #949494 ≈ --color-neutral-600. + */ + hasIndicator?: boolean; + /** + * When true an orange "For you" badge is rendered beside the org row. + * Figma: bg #ffe4d5 / text #b54400 ≈ --color-primary-500 / --color-primary-800. + */ + isForYou?: boolean; +} + +/** Props for a single right-panel section card ("This week" / "Trending"). */ +export interface OrgSidePanelProps { + title: string; + items: OrgSideEventItem[]; + onShowMore?: () => void; +} + +export interface OrgProps extends ComponentPropsWithoutRef<'div'> { + // ── Sidebar ── + activeNavItem?: SideBarItemId; + onNavigate?: (id: SideBarItemId) => void; + + // ── Org info ── + orgName?: string; + orgDescription?: string; + orgAvatarUrl?: string; + /** Background image URL for the cover banner. Falls back to the neutral surface colour. */ + coverImageUrl?: string; + /** + * When true a "verified RSO" badge is shown beside the org name. + * Figma annotation: "Hover to show : this is a registered student organization at Cornell". + */ + isVerified?: boolean; + /** Category and relevance tags shown beneath the org description. */ + orgTags?: OrgTag[]; + + // ── Loop Summary ── + loopSummary?: string; + + // ── Action buttons ── + isFollowing?: boolean; + onFollow?: () => void; + onWebsite?: () => void; + onEmail?: () => void; + + // ── Posts feed ── + posts?: DashboardPostProps[]; + feedSearchValue?: string; + onFeedSearchChange?: (value: string) => void; + onFeedSearchClear?: () => void; + /** Display label for the tag filter button, e.g. "All tags". */ + tagFilter?: string; + /** Called when the tag filter button is clicked (caller shows a picker). */ + onTagFilterChange?: () => void; + /** Display label for the time filter button, e.g. "All time". */ + timeFilter?: string; + /** Called when the time filter button is clicked (caller shows a picker). */ + onTimeFilterChange?: () => void; + + // ── Right panel ── + sidePanelSearchValue?: string; + onSidePanelSearchChange?: (value: string) => void; + onSidePanelSearchClear?: () => void; + sidePanels?: OrgSidePanelProps[]; +} + +// ─── OrgTagPill ─────────────────────────────────────────────────────────────── + +/** + * Renders a neutral or primary (orange "For you") pill tag for the org header. + * Uses the Tag design-system component for neutral; a custom span for primary, + * matching the "For you" badge style used consistently across Home / Subscriptions. + */ +function OrgTagPill({ label, variant = 'neutral' }: OrgTag) { + if (variant === 'neutral') { + return {label}; + } + return ( + + {label} + + ); +} + +// ─── OrgSideEventRow ───────────────────────────────────────────────────────── + +/** + * A single event row inside a right-panel section. + * Matches Figma nodes 119:420–119:478; mirrors the SideEventRow pattern from + * Subscriptions.tsx. + */ +function OrgSideEventRow({ item }: { item: OrgSideEventItem }) { + return ( +
+ {/* Event title — semibold, truncated to one line */} +

+ {item.title} +

+ + {/* Org row: avatar + name + optional indicator + optional "For you" tag */} +
+
+ {/* Circle avatar — 24 × 24 px (--space-6), initial-letter fallback */} + + {item.orgAvatarUrl ? ( + {item.orgName} + ) : ( + + {item.orgName.charAt(0).toUpperCase()} + + )} + + + {/* Org name */} + + {item.orgName} + + + {/* + * Indicator badge — small circular pill with a star icon. + * Figma bg: #949494 ≈ --color-neutral-600. + */} + {item.hasIndicator && ( + + )} +
+ + {/* + * "For you" badge. + * Figma: bg #ffe4d5 / text #b54400 ≈ --color-primary-500 / --color-primary-800. + */} + {item.isForYou && ( + + For you + + )} +
+
+ ); +} + +// ─── OrgSidePanelSection ────────────────────────────────────────────────────── + +/** + * A contextual event section card in the right panel ("This week" / "Trending"). + * Mirrors the SidePanelSection pattern from Subscriptions.tsx. + */ +function OrgSidePanelSection({ title, items, onShowMore }: OrgSidePanelProps) { + return ( +
+

+ {title} +

+ +
+ {items.map((item, i) => ( + + ))} +
+ + {/* + * "Show more" link — Figma: #0074bc ≈ --color-link (--color-secondary-600 #427fb4). + */} + {onShowMore && ( + + )} +
+ ); +} + +// ─── Org ────────────────────────────────────────────────────────────────────── + +/** + * Org page layout — three-column shell: + * + * ┌────────────┬──────────────────────────────────┬────────────────┐ + * │ SideBar │ Main content │ Right panel │ + * │ (215px) │ (flex-1) │ (334px) │ + * │ │ ┌ Cover banner (240 px) ──────┐ │ SearchBar │ + * │ Home │ │ [action buttons] │ │ This week │ + * │ Bookmarks │ └─────────────────────────────┘ │ Trending │ + * │ Subs │ [org avatar, overlapping cover] │ │ + * │ ──────── │ Org name + verified badge │ │ + * │ Profile │ Description │ │ + * │ │ Tags │ │ + * │ │ Loop Summary card │ │ + * │ │ ───────────────────────────── │ │ + * │ │ [search] [All tags] [All time] │ │ + * │ │ DashboardPost × n │ │ + * └────────────┴──────────────────────────────────┴────────────────┘ + */ +export function Org({ + activeNavItem, + onNavigate, + orgName = 'Association of Computer Science Undergraduates (ACSU)', + orgDescription = 'CS organization for undergrads looking to find community.', + orgAvatarUrl, + coverImageUrl, + isVerified, + orgTags = [], + loopSummary, + isFollowing = false, + onFollow, + onWebsite, + onEmail, + posts = [], + feedSearchValue, + onFeedSearchChange, + onFeedSearchClear, + tagFilter = 'All tags', + onTagFilterChange, + timeFilter = 'All time', + onTimeFilterChange, + sidePanelSearchValue, + onSidePanelSearchChange, + onSidePanelSearchClear, + sidePanels = [], + className, + ...rest +}: OrgProps) { + return ( +
+ {/* ── Left sidebar ── */} + + + {/* ── Main content ── */} +
+ {/* + * Relative wrapper: contains the cover banner and the action buttons row. + * The org avatar is absolutely positioned within this wrapper so it + * overlaps the bottom edge of the cover. + * + * Wrapper height ≈ cover (240 px) + action row (~56 px) = ~296 px. + * Avatar: top = 179 px, size = 121 px → bottom at 300 px (~4 px overflow). + * Figma: org-info section starts at top = 296 px with pt = 16 px, + * placing the first text line at ~312 px (below the avatar). + */} +
+ {/* Cover banner — Figma: h-240px, bg #d9d9d9 ≈ --color-surface-raised */} +
+ + {/* + * Action buttons row — right-aligned below the cover banner. + * py-2 (8 px × 2) + button height (~40 px) ≈ 56 px total, + * placing the org-info section at ~296 px from the wrapper top. + */} +
+ {/* Website / globe button — icon-only, bg Neutral/200 */} + + + {/* Email button — icon-only, bg Neutral/200 */} + + + {/* + * Follow button — Figma: bg #909090 ≈ --color-neutral-600, white text + star icon. + * Toggles to a lighter "Following" state when isFollowing is true. + */} + +
+ + {/* + * Org avatar — 121 × 121 px circle, absolutely positioned to overlap the + * cover banner. Figma: left = 24 px, top = 179 px (= 11.1875 rem). + * Ring provides visual separation from the cover image. + */} + + {orgAvatarUrl ? ( + {orgName} + ) : ( + + {orgName.charAt(0).toUpperCase()} + + )} + +
+ + {/* ── Org info section ── */} + {/* + * pt-4 (16 px) provides enough clearance below the ~4 px avatar overflow and + * matches the Figma org-info section's own pt-16 (content starts at ~312 px). + */} +
+ + {/* Org name + description + tags */} +
+ + {/* Name row + description */} +
+ {/* Org name + optional verified RSO badge */} +
+

+ {orgName} +

+ + {/* + * Verified RSO badge — Figma annotation: "Hover to show : this is a + * registered student organization at Cornell". Native `title` tooltip. + * Bg: #949494 ≈ --color-neutral-600; size ~20 px = --space-5. + */} + {isVerified && ( + + + )} +
+ + {/* Description — Figma: Inter Regular 16 px, #909090 ≈ --color-text-secondary */} + {orgDescription && ( +

+ {orgDescription} +

+ )} +
+ + {/* Org category / relevance tags */} + {orgTags.length > 0 && ( +
+ {orgTags.map((tag, i) => ( + + ))} +
+ )} +
+ + {/* Loop Summary card — Figma: Primary/600 border, shadow-2, px-24 py-16 */} + {loopSummary && ( + + )} +
+ + {/* Horizontal divider — Figma node 121:662 */} +
+ + {/* ── Right panel ── */} + {/* + * Mirrors the right-panel layout from Home and Subscriptions. + * Figma (node 119:413): w-326px, px-24, py-32, gap-24, left border. + * Uses --search-panel-width (334px) as the closest layout token. + */} + +
+ ); +} diff --git a/apps/dashboard/src/pages/profile.tsx b/apps/dashboard/src/pages/profile.tsx new file mode 100644 index 0000000..0aaa4b4 --- /dev/null +++ b/apps/dashboard/src/pages/profile.tsx @@ -0,0 +1,352 @@ +/** + * Profile — Loop Dashboard Popup + * + * Source: Figma "Incubator-design-file" › node 350:504 "Input" + * + * A centred modal card for collecting initial user profile information: + * • Greeting — "Hi {name}!" centred at the top + * • Major field — styled dropdown trigger (flex-1) + * • Grad Year field — styled dropdown trigger (shrink-0, content-width) + * • Minor field — optional, styled dropdown trigger (full-width) + * • Interests — editable neutral tag list with an "add" (+) trigger + * • Close button (×) — absolutely positioned in the top-right corner + * + * This component renders only the card itself. The caller is responsible for + * the backdrop overlay and centring, e.g.: + * + *
+ * + *
+ * + * Select-like fields: Figma shows pill inputs with a chevron-down icon. + * Each field exposes an `onClick` callback so the caller can mount a native + * , sheet, or popover; the card itself is display-only for the value. - * - * Card shadow: Figma specifies 0px 0px 6.5px 0px rgba(149,149,149,0.25) — - * used directly as there is no matching token in tokens.css. - * - * Greeting / field-label font size: Figma 22 px — used directly (1.375 rem) - * as no token covers this exact value, matching the Org.tsx precedent. - * - * Field label colour: Figma #949494 — approximated with --color-text-muted - * (--color-neutral-500 #adb5bd), the closest available muted-text token. - * - * All other colours, spacing, and font values reference CSS custom properties - * from src/styles/tokens.css — nothing else is hardcoded. + * All colours, spacing, and font values reference CSS custom properties from + * src/styles/tokens.css — nothing else is hardcoded. */ -import type { ComponentPropsWithoutRef } from "react"; -import { Tag } from "@app/ui"; +import { + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, +} from "react"; +import { Tag, Button } from "@app/ui"; // ─── Inline icon helpers ────────────────────────────────────────────────────── -// ChevronDownIcon and XIcon are not in shared/ui/src/assets; defined inline. function ChevronDownIcon({ className }: { className?: string }) { return ( @@ -76,6 +68,23 @@ function XIcon({ className }: { className?: string }) { ); } +function PlusIcon({ className }: { className?: string }) { + return ( + + ); +} + // ─── Shared typography class strings ───────────────────────────────────────── const BODY2_REGULAR = @@ -83,68 +92,235 @@ const BODY2_REGULAR = "text-[var(--font-size-body2)] leading-[var(--line-height-body2)] " + "tracking-[var(--letter-spacing-body2)]"; +// ─── Default option lists ───────────────────────────────────────────────────── + +const DEFAULT_MAJOR_OPTIONS: readonly string[] = [ + "Computer Science", + "Information Science", + "ECE", + "ORIE", + "Mechanical Engineering", + "Economics", + "Biology", + "Government", + "Psychology", + "Mathematics", +]; + +const DEFAULT_GRAD_YEAR_OPTIONS: readonly string[] = [ + "2026", + "2027", + "2028", + "2029", +]; + +const DEFAULT_MINOR_OPTIONS: readonly string[] = [ + "Linguistics", + "Business", + "Data Science", + "Game Design", + "Music", + "Inequality Studies", + "Statistics", + "Creative Writing", +]; + +const DEFAULT_INTEREST_OPTIONS: readonly string[] = [ + "Tech", + "Finance", + "Health", + "Education", + "Outdoors", + "Mentorship", + "Just for Fun", + "Entrepreneurship", + "Arts", + "Sports", + "Service", + "Research", +]; + // ─── Public types ───────────────────────────────────────────────────────────── export interface ProfileProps extends ComponentPropsWithoutRef<"div"> { - /** First name shown in the greeting, e.g. "Alex" → "Hi Alex!". Falls back to "Hi there!" when omitted. */ + /** First name shown in the greeting. Falls back to "there!" when omitted. */ userName?: string; - // ── Major ── major?: string; - /** Called when the Major field is clicked so the caller can show a picker. */ - onMajorChange?: () => void; + onMajorChange?: (next: string) => void; + majorOptions?: readonly string[]; - // ── Grad Year ── gradYear?: string; - /** Called when the Grad Year field is clicked so the caller can show a picker. */ - onGradYearChange?: () => void; + onGradYearChange?: (next: string) => void; + gradYearOptions?: readonly string[]; - // ── Minor ── minor?: string; - /** Called when the Minor field is clicked so the caller can show a picker. */ - onMinorChange?: () => void; + onMinorChange?: (next: string) => void; + minorOptions?: readonly string[]; - // ── Interests ── - /** Tag labels currently selected, e.g. ["Internships", "Early Career", "Tech"]. */ + /** Selected interest tag labels, e.g. ["Tech", "Health"]. */ interests?: string[]; - /** Called when the "+" add-interest chip is clicked. */ - onAddInterest?: () => void; + onInterestsChange?: (next: string[]) => void; + interestOptions?: readonly string[]; - // ── Dialog ── - /** Called when the × close button is clicked. */ + /** When true, render the post-save confirmation variant. */ + saved?: boolean; + /** Called when "Save changes" is clicked. */ + onSave?: () => void; + /** Called when × is clicked. */ onClose?: () => void; } +// ─── ProfilePicker — inline popover used by every select-like field ─────────── + +interface ProfilePickerProps { + open: boolean; + options: readonly string[]; + selected?: string; + onSelect: (value: string) => void; + onRequestClose: () => void; + /** Listbox accessibility id linked to the trigger. */ + listboxId: string; + /** Tailwind alignment for the menu — "start" left-aligns, "end" right-aligns. */ + align?: "start" | "end"; +} + +function ProfilePicker({ + open, + options, + selected, + onSelect, + onRequestClose, + listboxId, + align = "start", +}: ProfilePickerProps) { + const popRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onDocClick(e: MouseEvent) { + if ( + popRef.current && + e.target instanceof Node && + !popRef.current.contains(e.target) + ) { + onRequestClose(); + } + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onRequestClose(); + } + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open, onRequestClose]); + + if (!open) return null; + + const alignClass = align === "end" ? "right-0" : "left-0"; + + return ( +
+ {options.map((opt) => { + const isSelected = opt === selected; + return ( + + ); + })} +
+ ); +} + // ─── ProfileSelectField ─────────────────────────────────────────────────────── -/** - * A labelled select-like trigger. - * Renders a muted text label above a pill-shaped button that shows the current - * value and a chevron-down icon. Clicking calls `onClick`. - * - * Figma: bg white, Neutral/300 border, rounded-[16px], px-16, py-8, - * body-2 regular, Neutral/700 text, 24 × 24 chevron icon. - */ +interface ProfileSelectFieldProps { + label: string; + value?: string; + placeholder?: string; + options: readonly string[]; + onSelect: (value: string) => void; + className?: string; + pickerAlign?: "start" | "end"; + /** When true the trigger uses content-width (Grad Year). */ + compact?: boolean; +} + function ProfileSelectField({ label, value, placeholder, - onClick, + options, + onSelect, className, -}: { - label: string; - value?: string; - placeholder?: string; - onClick?: () => void; - className?: string; -}) { + pickerAlign, + compact = false, +}: ProfileSelectFieldProps) { + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + const reactId = useId(); + const listboxId = `${reactId}-listbox`; + + // Close picker when major/year/minor changes externally + useEffect(() => { + if (!open) return; + function onDocMouseDown(e: MouseEvent) { + if ( + wrapperRef.current && + e.target instanceof Node && + !wrapperRef.current.contains(e.target) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", onDocMouseDown); + return () => document.removeEventListener("mousedown", onDocMouseDown); + }, [open]); + return (
- {/* Field label — Figma: Regular 16 px, #949494 ≈ --color-text-muted */} - {/* Trigger button — bg white, Neutral/300 border, rounded-card */} + + setOpen(false)} + onSelect={(next) => { + onSelect(next); + setOpen(false); + }} + /> +
+ ); +} + +// ─── InterestPicker ─────────────────────────────────────────────────────────── + +interface InterestPickerProps { + options: readonly string[]; + selected: string[]; + onSelect: (value: string) => void; + onRequestClose: () => void; +} + +function InterestPicker({ + options, + selected, + onSelect, + onRequestClose, +}: InterestPickerProps) { + const popRef = useRef(null); + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if ( + popRef.current && + e.target instanceof Node && + !popRef.current.contains(e.target) + ) { + onRequestClose(); + } + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onRequestClose(); + } + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [onRequestClose]); + + const remaining = options.filter((o) => !selected.includes(o)); + + return ( +
+ {remaining.length === 0 ? ( + + All set! + + ) : ( + remaining.map((opt) => ( + + )) + )} +
+ ); +} + +// ─── SavedState — "Recalibrating your feed…" variant (Figma 633:4779) ──────── + +function SavedState() { + const [progress, setProgress] = useState(0); + + useEffect(() => { + // Animate 0 → 100% over ~1.2s using a single rAF-driven setTimeout chain. + const start = performance.now(); + let raf = 0; + const tick = (now: number) => { + const pct = Math.min(100, ((now - start) / 1200) * 100); + setProgress(pct); + if (pct < 100) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + return ( +
+

+ Recalibrating your feed… +

+
+
+
); } // ─── Profile ────────────────────────────────────────────────────────────────── -/** - * Profile popup card layout: - * - * ┌──────────────────────────────────────┐ - * │ × │ ← close button (absolute, top-right) - * │ Hi {userName}! │ ← greeting, centred - * │ │ - * │ Major ──────────────── Grad Year │ ← side-by-side, major flex-1 - * │ [ Select major ▾ ] [Year▾]│ - * │ │ - * │ Minor (optional) │ - * │ [ Select minor ▾ ] │ - * │ │ - * │ Interests │ - * │ [Internships] [Early Career] [Tech] │ - * │ [+] │ - * └──────────────────────────────────────┘ - */ export function Profile({ userName, major, onMajorChange, + majorOptions = DEFAULT_MAJOR_OPTIONS, gradYear, onGradYearChange, + gradYearOptions = DEFAULT_GRAD_YEAR_OPTIONS, minor, onMinorChange, + minorOptions = DEFAULT_MINOR_OPTIONS, interests = [], - onAddInterest, + onInterestsChange, + interestOptions = DEFAULT_INTEREST_OPTIONS, + saved = false, + onSave, onClose, className, ...rest }: ProfileProps) { + const [interestPickerOpen, setInterestPickerOpen] = useState(false); + + const handleRemoveInterest = (label: string) => { + onInterestsChange?.(interests.filter((i) => i !== label)); + }; + + const handleAddInterest = (label: string) => { + if (interests.includes(label)) return; + onInterestsChange?.([...interests, label]); + setInterestPickerOpen(false); + }; + return (
- {/* - * Close button — absolutely positioned top-right. - * Figma (node 350:513): pr-16 pt-16, icon 16 × 16 px. - */} + {/* Close button (always visible, top-right) */} - {/* ── Form fields (node 350:528) — gap-16 between each field group ── */} -
- {/* - * Greeting — Figma: Inter SemiBold 22 px, #5f5f5f ≈ --color-neutral-700, - * centred within a full-width row. - * 22 px (1.375 rem) is used directly; no token covers this exact size. - */} -
+ {saved ? ( + + ) : ( + <> + {/* Greeting — Figma: DM Sans Bold 18px, centred */}

{userName ? `Hi ${userName}!` : "Hi there!"}

-
- - {/* - * Major + Grad Year row — Figma (node 350:816): flex gap-16 items-start. - * Major uses flex-1 (stretches); Grad Year uses shrink-0 (content-width). - */} -
- - -
- - {/* Minor row — Figma (node 350:823): flex items-start, field flex-1 */} -
- -
- - {/* Interests — Figma (node 350:544): flex-col gap-8, label + tag chips */} -
- {/* Label — same muted style as field labels */} - - Interests - - - {/* - * Tag chips — Figma (node 350:804): flex gap-8 items-start. - * Each selected interest is a neutral Tag. The "+" chip at the end - * triggers onAddInterest; its radius in Figma is rounded-[8px] which - * matches the Tag component's --radius-input (8px) default. - */} -
- {interests.map((interest) => ( - - {interest} - - ))} - - onAddInterest?.()} - className="cursor-pointer" - > - + - + + {/* Form fields */} +
+ {/* Major + Grad Year row */} +
+ onMajorChange?.(v)} + className="min-w-0 flex-1" + /> + onGradYearChange?.(v)} + className="shrink-0" + pickerAlign="end" + compact + /> +
+ + {/* Minor */} +
+ onMinorChange?.(v)} + className="min-w-0 flex-1" + /> +
+ + {/* Interests */} +
+ + Interests + + +
+ {interests.map((label) => ( + handleRemoveInterest(label)} + > + {label} + + ))} + + +
+ + {interestPickerOpen && ( + setInterestPickerOpen(false)} + /> + )} +
-
-
+ + {/* Save changes */} + + + )}
); } diff --git a/scripts/screenshot-bookmarks.mjs b/scripts/screenshot-bookmarks.mjs new file mode 100644 index 0000000..0c0d539 --- /dev/null +++ b/scripts/screenshot-bookmarks.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Screenshot /bookmarks across multiple desktop widths for visual QA. + * + * Usage: node scripts/screenshot-bookmarks.mjs [label] + * label: prefix for output filenames, e.g. "00-baseline" (default: "current") + * + * Outputs go to specs/iterations/. + */ +import { chromium } from "playwright"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const outDir = path.join(repoRoot, "specs", "iterations"); + +const label = process.argv[2] ?? "current"; +const url = process.env.BOOKMARKS_URL ?? "http://localhost:5174/bookmarks"; + +// Figma frame is 1280×832. Sweep 1280 and 1440 per spec. +const viewports = [ + { name: "1280", width: 1280, height: 832 }, + { name: "1440", width: 1440, height: 900 }, +]; + +fs.mkdirSync(outDir, { recursive: true }); + +const browser = await chromium.launch(); +try { + for (const vp of viewports) { + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + // Let fonts settle + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(400); + + const viewportFile = path.join(outDir, `bookmarks-${label}-${vp.name}.png`); + await page.screenshot({ path: viewportFile, fullPage: false }); + console.log("wrote", viewportFile); + + const fullFile = path.join( + outDir, + `bookmarks-${label}-${vp.name}-full.png`, + ); + await page.screenshot({ path: fullFile, fullPage: true }); + console.log("wrote", fullFile); + + await ctx.close(); + } +} finally { + await browser.close(); +} diff --git a/scripts/screenshot-org.mjs b/scripts/screenshot-org.mjs new file mode 100644 index 0000000..6ee18ba --- /dev/null +++ b/scripts/screenshot-org.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Screenshot /orgs/:slug across multiple desktop widths for visual QA. + * + * Usage: node scripts/screenshot-org.mjs [label] [slug] + * label: prefix for output filenames, e.g. "00-baseline" (default: "current") + * slug: org slug to load (default: "acsu" or env ORG_SLUG) + * + * Outputs go to specs/iterations/. + */ +import { chromium } from "playwright"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const outDir = path.join(repoRoot, "specs", "iterations"); + +const label = process.argv[2] ?? "current"; +const slug = process.argv[3] ?? process.env.ORG_SLUG ?? "acsu"; +const baseUrl = process.env.ORG_BASE_URL ?? "http://localhost:5174"; +const url = `${baseUrl}/orgs/${slug}`; + +// Figma frame is 1280×832. Capture 1280 and 1440. +const viewports = [ + { name: "1280", width: 1280, height: 832 }, + { name: "1440", width: 1440, height: 900 }, +]; + +fs.mkdirSync(outDir, { recursive: true }); + +const browser = await chromium.launch(); +try { + for (const vp of viewports) { + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + // Let fonts settle + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(400); + + const viewportFile = path.join( + outDir, + `org-${label}-${slug}-${vp.name}.png`, + ); + await page.screenshot({ path: viewportFile, fullPage: false }); + console.log("wrote", viewportFile); + + const fullFile = path.join( + outDir, + `org-${label}-${slug}-${vp.name}-full.png`, + ); + await page.screenshot({ path: fullFile, fullPage: true }); + console.log("wrote", fullFile); + + await ctx.close(); + } +} finally { + await browser.close(); +} diff --git a/scripts/screenshot-profile.mjs b/scripts/screenshot-profile.mjs new file mode 100644 index 0000000..7a766d5 --- /dev/null +++ b/scripts/screenshot-profile.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Screenshot /profile across multiple desktop widths and capture both states: + * • default — the editable profile-setup modal + * • saved — the "Recalibrating your feed…" confirmation that appears after + * clicking "Save changes" + * + * Usage: node scripts/screenshot-profile.mjs [label] + * label: prefix for output filenames (default: "profile") + * + * Outputs go to specs/iterations/. + */ +import { chromium } from "playwright"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const outDir = path.join(repoRoot, "specs", "iterations"); + +const label = process.argv[2] ?? "profile"; +const url = process.env.PROFILE_URL ?? "http://localhost:5174/profile"; + +const viewports = [ + { name: "1280", width: 1280, height: 832 }, + { name: "1440", width: 1440, height: 900 }, + { name: "1920", width: 1920, height: 1080 }, +]; + +fs.mkdirSync(outDir, { recursive: true }); + +const browser = await chromium.launch(); +try { + for (const vp of viewports) { + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(400); + + // Default state + const defaultFile = path.join(outDir, `${label}-default-${vp.name}.png`); + await page.screenshot({ path: defaultFile, fullPage: false }); + console.log("wrote", defaultFile); + + // Trigger saved state by clicking the "Save changes" button + await page.getByRole("button", { name: "Save changes" }).click(); + // The progress bar animates over ~1.2s; capture at ~500 ms in to land + // mid-fill (matches Figma's ~46% reference). + await page.waitForTimeout(500); + + const savedFile = path.join(outDir, `${label}-saved-${vp.name}.png`); + await page.screenshot({ path: savedFile, fullPage: false }); + console.log("wrote", savedFile); + + await ctx.close(); + } +} finally { + await browser.close(); +} diff --git a/scripts/screenshot-search.mjs b/scripts/screenshot-search.mjs new file mode 100644 index 0000000..63d2dff --- /dev/null +++ b/scripts/screenshot-search.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Screenshot the search experience in its three states. + * + * 1. default — input focused, "Recent" dropdown showing + * 2. typed — input has a query, live-suggestions dropdown showing + * 3. results — Enter pressed, Top/Events/Orgs toggle + result feed + * + * Outputs: specs/iterations/search-{state}-{viewport}.png + * + * Usage: node scripts/screenshot-search.mjs [label] + * label: optional prefix, default "search" + * + * Requires the dev server to be running at SEARCH_URL (default + * http://localhost:5174). Override via env vars HOME_URL / SEARCH_URL. + */ +import { chromium } from "playwright"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const outDir = path.join(repoRoot, "specs", "iterations"); + +const label = process.argv[2] ?? "search"; +const homeUrl = process.env.HOME_URL ?? "http://localhost:5174/home"; +const searchUrl = process.env.SEARCH_URL ?? "http://localhost:5174/search"; + +// Figma frame is 1280×832. Search experience is desktop-first. +const viewports = [{ name: "1280", width: 1280, height: 832 }]; + +fs.mkdirSync(outDir, { recursive: true }); + +const browser = await chromium.launch(); +try { + for (const vp of viewports) { + const ctx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + + // ── State 1: default (focused, empty) ──────────────────────────────── + await page.goto(homeUrl, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(300); + const searchInput = page.getByRole("searchbox").first(); + await searchInput.click(); + await page.waitForTimeout(200); + const file1 = path.join(outDir, `${label}-default-${vp.name}.png`); + await page.screenshot({ path: file1, fullPage: false }); + console.log("wrote", file1); + + // ── State 2: typed ─────────────────────────────────────────────────── + await searchInput.fill("horse"); + await page.waitForTimeout(200); + const file2 = path.join(outDir, `${label}-typed-${vp.name}.png`); + await page.screenshot({ path: file2, fullPage: false }); + console.log("wrote", file2); + + // ── State 3: results (deep-link to /search?q=horses) ───────────────── + await page.goto(`${searchUrl}?q=horses`, { + waitUntil: "networkidle", + timeout: 30_000, + }); + // Click somewhere neutral so the input isn't auto-focused/dropdown open. + await page.mouse.click(2, 2); + await page.waitForTimeout(300); + const file3 = path.join(outDir, `${label}-results-${vp.name}.png`); + await page.screenshot({ path: file3, fullPage: false }); + console.log("wrote", file3); + + const file3full = path.join(outDir, `${label}-results-${vp.name}-full.png`); + await page.screenshot({ path: file3full, fullPage: true }); + console.log("wrote", file3full); + + await ctx.close(); + } +} finally { + await browser.close(); +} diff --git a/scripts/screenshot-subscriptions.mjs b/scripts/screenshot-subscriptions.mjs new file mode 100644 index 0000000..03bdf60 --- /dev/null +++ b/scripts/screenshot-subscriptions.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Screenshot /subscriptions across multiple desktop widths for visual QA. + * + * Usage: node scripts/screenshot-subscriptions.mjs [label] + * label: prefix for output filenames, e.g. "00-baseline" + * (default: "current") + * + * Captures two states per viewport: + * •
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick?.(); + } + } + : undefined + } className={[ "flex w-full items-center gap-[var(--space-3)]", "p-[var(--space-1-5)]", "rounded-[var(--radius-input)]", "bg-[var(--color-surface)]", - "cursor-pointer", + interactive ? "cursor-pointer" : "", "hover:bg-[var(--color-surface-subtle)]", "transition-colors duration-150", + "focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-700)]", ].join(" ")} > @@ -188,7 +218,7 @@ function RsvpEventRow({ event }: { event: RsvpEvent }) { // ─── ClubItem ───────────────────────────────────────────────────────────────── -function ClubItem({ club }: { club: Club }) { +function ClubItem({ club, onClick }: { club: Club; onClick?: () => void }) { const count = club.notificationCount ?? 0; const fallback = fallbackColorsForName(club.name); @@ -232,18 +262,34 @@ function ClubItem({ club }: { club: Club }) { following: true, }; + const interactive = Boolean(onClick); return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick?.(); + } + } + : undefined + } + role={interactive ? "button" : undefined} + tabIndex={interactive ? 0 : undefined} + aria-label={interactive ? `Open ${club.name}` : undefined} className={[ "relative", "flex w-full items-center gap-[var(--space-3)]", "p-[var(--space-1-5)]", "rounded-[var(--radius-input)]", - "cursor-pointer", + interactive ? "cursor-pointer" : "", "hover:bg-[var(--color-surface-subtle)]", "transition-colors duration-150", + "focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-700)]", ].join(" ")} > @@ -314,6 +360,8 @@ function ClubItem({ club }: { club: Club }) { export function SearchPanel({ rsvpGroups = [], clubs = [], + onClubClick, + onRsvpClick, className, ...rest }: SearchPanelProps) { @@ -356,7 +404,11 @@ export function SearchPanel({

{group.events.map((event, i) => ( - + onRsvpClick(event) : undefined} + /> ))}
))} @@ -371,7 +423,11 @@ export function SearchPanel({
{clubs.map((club) => ( - + onClubClick(club) : undefined} + /> ))}
diff --git a/shared/ui/src/index.ts b/shared/ui/src/index.ts index aa63d0c..1535b5e 100644 --- a/shared/ui/src/index.ts +++ b/shared/ui/src/index.ts @@ -14,3 +14,4 @@ export * from "./components/Cards/DashboardEventCard"; export * from "./components/Cards/DashboardPost"; export * from "./components/Cards/ExtensionEventCard"; export * from "./components/Cards/LoopSummary"; +export * from "./utils/fallbackColors"; From 9a5eb8ea2312789faf5b9464e45ad9fe877422bb Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 07:10:23 -0400 Subject: [PATCH 16/43] Add admin internal management page --- convex/_generated/api.d.ts | 12 + convex/_shared/adminToken.ts | 13 + convex/crons.ts | 13 + convex/gmailConnection.ts | 38 ++ convex/gmailOAuth.ts | 254 +++++++++++ convex/http.ts | 4 + convex/ingestion.ts | 710 +++++++++++++++++++++++++++++++ convex/listservAdmin.ts | 775 +++++++++++++++++++++++++++++++++ convex/schema.ts | 167 ++++++++ eslint.config.js | 2 +- src/App.css | 42 -- src/App.tsx | 4 +- src/pages/Admin.tsx | 804 +++++++++++++++++++++++++++++++++++ 13 files changed, 2794 insertions(+), 44 deletions(-) create mode 100644 convex/_shared/adminToken.ts create mode 100644 convex/crons.ts create mode 100644 convex/gmailConnection.ts create mode 100644 convex/gmailOAuth.ts create mode 100644 convex/ingestion.ts create mode 100644 convex/listservAdmin.ts delete mode 100644 src/App.css create mode 100644 src/pages/Admin.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index d47c3e3..0a593b7 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,8 +8,14 @@ * @module */ +import type * as _shared_adminToken from "../_shared/adminToken.js"; import type * as auth from "../auth.js"; +import type * as crons from "../crons.js"; +import type * as gmailConnection from "../gmailConnection.js"; +import type * as gmailOAuth from "../gmailOAuth.js"; import type * as http from "../http.js"; +import type * as ingestion from "../ingestion.js"; +import type * as listservAdmin from "../listservAdmin.js"; import type { ApiFromModules, @@ -18,8 +24,14 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + "_shared/adminToken": typeof _shared_adminToken; auth: typeof auth; + crons: typeof crons; + gmailConnection: typeof gmailConnection; + gmailOAuth: typeof gmailOAuth; http: typeof http; + ingestion: typeof ingestion; + listservAdmin: typeof listservAdmin; }>; /** diff --git a/convex/_shared/adminToken.ts b/convex/_shared/adminToken.ts new file mode 100644 index 0000000..0b8e38b --- /dev/null +++ b/convex/_shared/adminToken.ts @@ -0,0 +1,13 @@ +declare const process: { env: Record }; + +export function requireAdminToken(token: string) { + const expected = process.env.ADMIN_TOKEN; + + if (!expected) { + throw new Error("ADMIN_TOKEN is not configured."); + } + + if (token !== expected) { + throw new Error("Invalid admin token."); + } +} diff --git a/convex/crons.ts b/convex/crons.ts new file mode 100644 index 0000000..398be8b --- /dev/null +++ b/convex/crons.ts @@ -0,0 +1,13 @@ +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +crons.interval( + "poll listserv inbox", + { minutes: 10 }, + internal.ingestion.pollListservInbox, + { trigger: "cron" }, +); + +export default crons; diff --git a/convex/gmailConnection.ts b/convex/gmailConnection.ts new file mode 100644 index 0000000..0517f85 --- /dev/null +++ b/convex/gmailConnection.ts @@ -0,0 +1,38 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "./_generated/server"; + +const CONNECTION_KEY = "primary"; + +export const getConnection = internalQuery({ + args: {}, + handler: async (ctx) => { + const connection = await ctx.db + .query("gmailConnections") + .withIndex("by_key", (q) => q.eq("key", CONNECTION_KEY)) + .unique(); + + if (!connection || connection.status !== "connected") return null; + return { + email: connection.email, + refreshToken: connection.refreshToken, + }; + }, +}); + +export const markInvalid = internalMutation({ + args: { error: v.string() }, + handler: async (ctx, args) => { + const connection = await ctx.db + .query("gmailConnections") + .withIndex("by_key", (q) => q.eq("key", CONNECTION_KEY)) + .unique(); + + if (!connection) return; + + await ctx.db.patch(connection._id, { + status: "invalid", + lastError: args.error, + updatedAt: Date.now(), + }); + }, +}); diff --git a/convex/gmailOAuth.ts b/convex/gmailOAuth.ts new file mode 100644 index 0000000..e2f3438 --- /dev/null +++ b/convex/gmailOAuth.ts @@ -0,0 +1,254 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { httpAction, internalMutation, internalQuery, query } from "./_generated/server"; +import { requireAdminToken } from "./_shared/adminToken"; + +declare const process: { env: Record }; + +const CONNECTION_KEY = "primary"; +const GMAIL_SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", +]; +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GMAIL_PROFILE_URL = "https://gmail.googleapis.com/gmail/v1/users/me/profile"; + +type GoogleTokenResponse = { + access_token?: string; + refresh_token?: string; + scope?: string; + error?: string; + error_description?: string; +}; + +type GmailProfileResponse = { + emailAddress?: string; +}; + +export const connectionStatus = query({ + args: { token: v.string() }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + + const connection = await ctx.db + .query("gmailConnections") + .withIndex("by_key", (q) => q.eq("key", CONNECTION_KEY)) + .unique(); + + if (!connection) return null; + + return { + email: connection.email, + scopes: connection.scopes, + status: connection.status, + connectedAt: connection.connectedAt, + updatedAt: connection.updatedAt, + lastError: connection.lastError, + }; + }, +}); + +export const start = httpAction(async (ctx, request) => { + try { + const url = new URL(request.url); + const token = url.searchParams.get("token") ?? ""; + requireAdminToken(token); + + const state = crypto.randomUUID(); + await ctx.runMutation(internal.gmailOAuth.createOAuthState, { state }); + + const redirectUri = getRedirectUri(request); + const params = new URLSearchParams({ + client_id: getRequiredEnv("GOOGLE_OAUTH_CLIENT_ID"), + redirect_uri: redirectUri, + response_type: "code", + scope: GMAIL_SCOPES.join(" "), + access_type: "offline", + prompt: "consent", + state, + }); + + return Response.redirect(`${GOOGLE_AUTH_URL}?${params.toString()}`, 302); + } catch (error) { + return textResponse(formatError(error), 400); + } +}); + +export const callback = httpAction(async (ctx, request) => { + const url = new URL(request.url); + const state = url.searchParams.get("state") ?? ""; + const code = url.searchParams.get("code") ?? ""; + const oauthError = url.searchParams.get("error"); + + if (oauthError) return htmlResponse(renderResultPage("Gmail connection failed", oauthError), 400); + if (!state || !code) return htmlResponse(renderResultPage("Gmail connection failed", "Missing OAuth state or code."), 400); + + try { + const stateRow = await ctx.runQuery(internal.gmailOAuth.getValidOAuthState, { state }); + if (!stateRow) throw new Error("OAuth state is invalid or expired."); + + const redirectUri = getRedirectUri(request); + const tokenResponse = await exchangeCodeForTokens(code, redirectUri); + if (!tokenResponse.refresh_token) { + throw new Error("Google did not return a refresh token. Try reconnecting and approving consent again."); + } + if (!tokenResponse.access_token) throw new Error("Google did not return an access token."); + + const profile = await getGmailProfile(tokenResponse.access_token); + const email = profile.emailAddress?.toLowerCase(); + if (!email) throw new Error("Could not read Gmail profile email."); + + await ctx.runMutation(internal.gmailOAuth.storeConnection, { + state, + email, + refreshToken: tokenResponse.refresh_token, + scopes: tokenResponse.scope?.split(" ") ?? GMAIL_SCOPES, + }); + + return htmlResponse(renderResultPage("Gmail connected", `Connected ${email}. You can close this tab.`)); + } catch (error) { + return htmlResponse(renderResultPage("Gmail connection failed", formatError(error)), 400); + } +}); + +export const createOAuthState = internalMutation({ + args: { state: v.string() }, + handler: async (ctx, args) => { + const now = Date.now(); + await ctx.db.insert("gmailOAuthStates", { + state: args.state, + createdAt: now, + expiresAt: now + 10 * 60 * 1000, + }); + }, +}); + +export const getValidOAuthState = internalQuery({ + args: { state: v.string() }, + handler: async (ctx, args) => { + const state = await ctx.db + .query("gmailOAuthStates") + .withIndex("by_state", (q) => q.eq("state", args.state)) + .unique(); + + if (!state || state.usedAt || state.expiresAt < Date.now()) return null; + return { _id: state._id }; + }, +}); + +export const storeConnection = internalMutation({ + args: { + state: v.string(), + email: v.string(), + refreshToken: v.string(), + scopes: v.array(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const state = await ctx.db + .query("gmailOAuthStates") + .withIndex("by_state", (q) => q.eq("state", args.state)) + .unique(); + if (state) await ctx.db.patch(state._id, { usedAt: now }); + + const existing = await ctx.db + .query("gmailConnections") + .withIndex("by_key", (q) => q.eq("key", CONNECTION_KEY)) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { + email: args.email, + refreshToken: args.refreshToken, + scopes: args.scopes, + status: "connected", + updatedAt: now, + lastError: undefined, + }); + return; + } + + await ctx.db.insert("gmailConnections", { + key: CONNECTION_KEY, + email: args.email, + refreshToken: args.refreshToken, + scopes: args.scopes, + status: "connected", + connectedAt: now, + updatedAt: now, + }); + }, +}); + +function getRedirectUri(request: Request) { + const configured = process.env.GOOGLE_OAUTH_REDIRECT_URI; + if (configured) return configured; + + const url = new URL(request.url); + return `${url.origin}/gmail/oauth/callback`; +} + +async function exchangeCodeForTokens(code: string, redirectUri: string) { + const params = new URLSearchParams({ + code, + client_id: getRequiredEnv("GOOGLE_OAUTH_CLIENT_ID"), + client_secret: getRequiredEnv("GOOGLE_OAUTH_CLIENT_SECRET"), + redirect_uri: redirectUri, + grant_type: "authorization_code", + }); + + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + const body = (await response.json()) as GoogleTokenResponse; + + if (!response.ok) { + throw new Error(body.error_description ?? body.error ?? "Google token exchange failed."); + } + + return body; +} + +async function getGmailProfile(accessToken: string) { + const response = await fetch(GMAIL_PROFILE_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) throw new Error(`Gmail profile request failed: ${await response.text()}`); + return (await response.json()) as GmailProfileResponse; +} + +function getRequiredEnv(name: string) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not configured.`); + return value; +} + +function textResponse(text: string, status = 200) { + return new Response(text, { status, headers: { "Content-Type": "text/plain; charset=utf-8" } }); +} + +function htmlResponse(html: string, status = 200) { + return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); +} + +function renderResultPage(title: string, message: string) { + return `${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(message)}

`; +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatError(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "Unknown error."; +} diff --git a/convex/http.ts b/convex/http.ts index 1816e5b..90676cc 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,8 +1,12 @@ import { httpRouter } from "convex/server"; import { auth } from "./auth"; +import { callback, start } from "./gmailOAuth"; const http = httpRouter(); auth.addHttpRoutes(http); +http.route({ path: "/gmail/oauth/start", method: "GET", handler: start }); +http.route({ path: "/gmail/oauth/callback", method: "GET", handler: callback }); + export default http; diff --git a/convex/ingestion.ts b/convex/ingestion.ts new file mode 100644 index 0000000..133bd5e --- /dev/null +++ b/convex/ingestion.ts @@ -0,0 +1,710 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { internalAction, internalMutation, internalQuery } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import type { ActionCtx } from "./_generated/server"; + +declare const process: { env: Record }; + +const GMAIL_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const GMAIL_MESSAGES_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages"; +const GMAIL_HISTORY_URL = "https://gmail.googleapis.com/gmail/v1/users/me/history"; +const GMAIL_PROFILE_URL = "https://gmail.googleapis.com/gmail/v1/users/me/profile"; +const GMAIL_BATCH_ENDPOINT = "https://www.googleapis.com/batch/gmail/v1"; +const HISTORY_STATE_KEY = "gmail_history_id"; +const MESSAGES_PER_PAGE = 100; +const MAX_BOOTSTRAP_MESSAGES = 250; +const BATCH_SIZE = 50; + +type GmailHeader = { name?: string; value?: string }; + +type GmailMessagePart = { + mimeType?: string; + body?: { data?: string; size?: number }; + parts?: GmailMessagePart[]; + headers?: GmailHeader[]; +}; + +type GmailFullMessage = { + id?: string; + threadId?: string; + internalDate?: string; + payload?: GmailMessagePart; +}; + +type GmailListResponse = { + messages?: Array<{ id: string; threadId?: string }>; + nextPageToken?: string; +}; + +type GmailHistoryResponse = { + history?: Array<{ + messagesAdded?: Array<{ message?: { id?: string } }>; + }>; + nextPageToken?: string; + historyId?: string; +}; + +type GmailProfileResponse = { + historyId?: string; +}; + +type ParsedEmail = { + gmailMessageId: string; + threadId?: string; + sender: string; + senderEmail: string; + to: string[]; + cc: string[]; + subject: string; + receivedAt: number; + bodyText: string; + bodyHtml: string; + headers: Array<{ name: string; value: string }>; +}; + +type StoredParsedEmail = ParsedEmail & { + listservId?: Id<"listservs">; +}; + +type MatchableListserv = { + _id: Id<"listservs">; + listEmail: string; + senderEmails: string[]; +}; + +type IngestionRunResult = { + fetched: number; + unseen: number; + stored: number; +}; + +type FetchedMessageIds = { + messageIds: string[]; + historyId?: string; +}; + +type IngestionStateSnapshot = { + value?: string; +}; + +type GmailConnectionSnapshot = { + email: string; + refreshToken: string; +}; + +export const pollListservInbox = internalAction({ + args: { trigger: v.optional(v.union(v.literal("cron"), v.literal("manual"))) }, + handler: async (ctx, args): Promise => { + const runId: Id<"ingestionRuns"> = await ctx.runMutation( + internal.ingestion.startIngestionRun, + { trigger: args.trigger ?? "cron" }, + ); + + await ctx.runMutation(internal.ingestion.markIngestionRunning, { + key: HISTORY_STATE_KEY, + }); + + try { + const accessToken = await refreshAccessToken(ctx); + const state = (await ctx.runQuery(internal.ingestion.getIngestionState, { + key: HISTORY_STATE_KEY, + })) as IngestionStateSnapshot | null; + + const fetched: FetchedMessageIds = state?.value + ? await fetchMessagesSinceHistory(accessToken, state.value) + : await fetchRecentMessages(accessToken); + + const unseenIds = (await ctx.runQuery(internal.ingestion.filterUnseenMessages, { + gmailMessageIds: fetched.messageIds, + })) as string[]; + + let stored = 0; + if (unseenIds.length > 0) { + const [messages, listservs] = (await Promise.all([ + batchFetchMessages(unseenIds, accessToken), + ctx.runQuery(internal.ingestion.getMatchableListservs), + ])) as [GmailFullMessage[], MatchableListserv[]]; + + const parsed: StoredParsedEmail[] = messages.flatMap((message: GmailFullMessage) => { + const email = parseGmailMessage(message); + if (!email) return []; + + return [ + { + ...email, + listservId: matchListserv(email, listservs), + }, + ]; + }); + + if (parsed.length > 0) { + const result = await ctx.runMutation(internal.ingestion.storeParsedMessages, { + messages: parsed, + }); + stored = result.stored; + } + } + + if (fetched.historyId) { + await ctx.runMutation(internal.ingestion.markIngestionSucceeded, { + key: HISTORY_STATE_KEY, + value: fetched.historyId, + }); + } else { + await ctx.runMutation(internal.ingestion.markIngestionSucceeded, { + key: HISTORY_STATE_KEY, + }); + } + + const result = { + fetched: fetched.messageIds.length, + unseen: unseenIds.length, + stored, + }; + + await ctx.runMutation(internal.ingestion.finishIngestionRun, { + runId, + status: "completed", + ...result, + }); + + return result; + } catch (error) { + await ctx.runMutation(internal.ingestion.finishIngestionRun, { + runId, + status: "failed", + fetched: 0, + unseen: 0, + stored: 0, + error: formatError(error), + }); + await ctx.runMutation(internal.ingestion.markIngestionFailed, { + key: HISTORY_STATE_KEY, + error: formatError(error), + }); + throw error; + } + }, +}); + +export const getIngestionState = internalQuery({ + args: { key: v.string() }, + handler: async (ctx, args) => { + return ctx.db + .query("listservIngestionState") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .unique(); + }, +}); + +export const getMatchableListservs = internalQuery({ + args: {}, + handler: async (ctx) => { + const listservs = await ctx.db.query("listservs").collect(); + return listservs + .filter((listserv) => listserv.status === "active" || listserv.status === "joining") + .map((listserv) => ({ + _id: listserv._id, + listEmail: listserv.listEmail, + senderEmails: listserv.senderEmails, + })); + }, +}); + +export const filterUnseenMessages = internalQuery({ + args: { gmailMessageIds: v.array(v.string()) }, + handler: async (ctx, args) => { + const unseen: string[] = []; + + for (const id of args.gmailMessageIds) { + const existing = await ctx.db + .query("listservMessages") + .withIndex("by_gmail_message_id", (q) => q.eq("gmailMessageId", id)) + .unique(); + if (!existing) unseen.push(id); + } + + return unseen; + }, +}); + +export const startIngestionRun = internalMutation({ + args: { trigger: v.union(v.literal("cron"), v.literal("manual")) }, + handler: async (ctx, args) => { + return ctx.db.insert("ingestionRuns", { + trigger: args.trigger, + status: "running", + startedAt: Date.now(), + fetched: 0, + unseen: 0, + stored: 0, + }); + }, +}); + +export const finishIngestionRun = internalMutation({ + args: { + runId: v.id("ingestionRuns"), + status: v.union(v.literal("completed"), v.literal("failed")), + fetched: v.number(), + unseen: v.number(), + stored: v.number(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.runId, { + status: args.status, + finishedAt: Date.now(), + fetched: args.fetched, + unseen: args.unseen, + stored: args.stored, + error: args.error, + }); + }, +}); + +export const markIngestionRunning = internalMutation({ + args: { key: v.string() }, + handler: async (ctx, args) => { + const now = Date.now(); + const existing = await ctx.db + .query("listservIngestionState") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { + status: "running", + lastStartedAt: now, + lastError: undefined, + updatedAt: now, + }); + return; + } + + await ctx.db.insert("listservIngestionState", { + key: args.key, + status: "running", + lastStartedAt: now, + updatedAt: now, + }); + }, +}); + +export const markIngestionSucceeded = internalMutation({ + args: { key: v.string(), value: v.optional(v.string()) }, + handler: async (ctx, args) => { + const now = Date.now(); + const existing = await ctx.db + .query("listservIngestionState") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .unique(); + + const patch = { + value: args.value ?? existing?.value, + status: "idle" as const, + lastSucceededAt: now, + lastError: undefined, + updatedAt: now, + }; + + if (existing) { + await ctx.db.patch(existing._id, patch); + return; + } + + await ctx.db.insert("listservIngestionState", { + key: args.key, + ...patch, + }); + }, +}); + +export const markIngestionFailed = internalMutation({ + args: { key: v.string(), error: v.string() }, + handler: async (ctx, args) => { + const now = Date.now(); + const existing = await ctx.db + .query("listservIngestionState") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { + status: "failed", + lastError: args.error, + updatedAt: now, + }); + return; + } + + await ctx.db.insert("listservIngestionState", { + key: args.key, + status: "failed", + lastError: args.error, + updatedAt: now, + }); + }, +}); + +export const storeParsedMessages = internalMutation({ + args: { + messages: v.array( + v.object({ + gmailMessageId: v.string(), + threadId: v.optional(v.string()), + listservId: v.optional(v.id("listservs")), + sender: v.string(), + senderEmail: v.string(), + to: v.array(v.string()), + cc: v.array(v.string()), + subject: v.string(), + receivedAt: v.number(), + bodyText: v.string(), + bodyHtml: v.string(), + headers: v.array(v.object({ name: v.string(), value: v.string() })), + }), + ), + }, + handler: async (ctx, args) => { + const now = Date.now(); + let stored = 0; + + for (const message of args.messages) { + const existing = await ctx.db + .query("listservMessages") + .withIndex("by_gmail_message_id", (q) => + q.eq("gmailMessageId", message.gmailMessageId), + ) + .unique(); + + if (existing) continue; + + await ctx.db.insert("listservMessages", { + ...message, + processingStatus: "new", + createdAt: now, + }); + + if (message.listservId) { + await ctx.db.patch(message.listservId, { + lastReceivedAt: message.receivedAt, + updatedAt: now, + }); + } + + stored += 1; + } + + return { stored }; + }, +}); + +async function refreshAccessToken(ctx: ActionCtx) { + const connection = (await ctx.runQuery( + internal.gmailConnection.getConnection, + {}, + )) as GmailConnectionSnapshot | null; + + if (!connection) { + throw new Error("Gmail is not connected. Use the admin page to connect Gmail first."); + } + + const refreshToken = connection.refreshToken; + const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID; + const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET; + + if (!refreshToken || !clientId || !clientSecret) { + throw new Error("Gmail OAuth client env vars are not configured."); + } + + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch(GMAIL_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + const error = `Token refresh failed (${response.status}): ${await response.text()}`; + await ctx.runMutation(internal.gmailConnection.markInvalid, { error }); + throw new Error(error); + } + + const data = (await response.json()) as { access_token?: string }; + if (!data.access_token) throw new Error("Token refresh returned no access_token."); + + return data.access_token; +} + +async function fetchMessagesSinceHistory(accessToken: string, historyId: string) { + const ids: string[] = []; + let pageToken: string | undefined; + let latestHistoryId: string | undefined; + + do { + const url = new URL(GMAIL_HISTORY_URL); + url.searchParams.set("startHistoryId", historyId); + url.searchParams.set("historyTypes", "messageAdded"); + url.searchParams.set("maxResults", String(MESSAGES_PER_PAGE)); + if (pageToken) url.searchParams.set("pageToken", pageToken); + + const response = await gmailFetch(url.toString(), accessToken); + if (response.historyId) latestHistoryId = response.historyId; + + for (const historyItem of response.history ?? []) { + for (const added of historyItem.messagesAdded ?? []) { + if (added.message?.id) ids.push(added.message.id); + } + } + + pageToken = response.nextPageToken; + } while (pageToken); + + return { messageIds: [...new Set(ids)], historyId: latestHistoryId }; +} + +async function fetchRecentMessages(accessToken: string) { + const ids: string[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(GMAIL_MESSAGES_URL); + url.searchParams.set("maxResults", String(MESSAGES_PER_PAGE)); + if (pageToken) url.searchParams.set("pageToken", pageToken); + + const response = await gmailFetch(url.toString(), accessToken); + ids.push(...(response.messages?.map((message) => message.id) ?? [])); + pageToken = ids.length < MAX_BOOTSTRAP_MESSAGES ? response.nextPageToken : undefined; + } while (pageToken); + + const profile = await gmailFetch(GMAIL_PROFILE_URL, accessToken); + return { messageIds: [...new Set(ids)], historyId: profile.historyId }; +} + +async function batchFetchMessages(ids: string[], accessToken: string) { + const messages: GmailFullMessage[] = []; + + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const chunk = ids.slice(i, i + BATCH_SIZE); + const boundary = `batch_${crypto.randomUUID()}`; + const body = + chunk + .map( + (id) => + `--${boundary}\r\nContent-Type: application/http\r\n\r\nGET /gmail/v1/users/me/messages/${id}?format=full HTTP/1.1\r\n\r\n`, + ) + .join("") + `--${boundary}--`; + + const response = await fetch(GMAIL_BATCH_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": `multipart/mixed; boundary=${boundary}`, + }, + body, + }); + + if (!response.ok) { + throw new Error(`Gmail batch fetch failed (${response.status}): ${await response.text()}`); + } + + messages.push(...parseBatchResponse(await response.text())); + } + + return messages; +} + +async function gmailFetch(url: string, accessToken: string) { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new Error(`Gmail API error ${response.status}: ${await response.text()}`); + } + + return (await response.json()) as T; +} + +function parseBatchResponse(responseText: string) { + const results: GmailFullMessage[] = []; + const parts = responseText.split(/--batch_[^\r\n]+/); + + for (const part of parts) { + const jsonStart = part.indexOf("{"); + const jsonEnd = part.lastIndexOf("}"); + if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) continue; + + try { + const message = JSON.parse(part.slice(jsonStart, jsonEnd + 1)) as GmailFullMessage; + if (message.id) results.push(message); + } catch { + continue; + } + } + + return results; +} + +function parseGmailMessage(message: GmailFullMessage): ParsedEmail | null { + if (!message.id) return null; + + const headers = collectHeaders(message.payload); + const headerMap = new Map(headers.map((header) => [header.name.toLowerCase(), header.value])); + const sender = headerMap.get("from") ?? ""; + const senderEmail = extractEmailAddress(sender); + const subject = headerMap.get("subject") ?? ""; + const receivedAt = parseDate(headerMap.get("date")) ?? parseInternalDate(message.internalDate); + if (!receivedAt) return null; + + const { text, html } = extractBodies(message.payload); + + return { + gmailMessageId: message.id, + threadId: message.threadId, + sender, + senderEmail, + to: extractEmails(headerMap.get("to") ?? ""), + cc: extractEmails(headerMap.get("cc") ?? ""), + subject, + receivedAt, + bodyText: text, + bodyHtml: html, + headers, + }; +} + +function collectHeaders(part: GmailMessagePart | undefined) { + return (part?.headers ?? []) + .filter((header) => header.name && header.value !== undefined) + .map((header) => ({ name: header.name ?? "", value: header.value ?? "" })); +} + +function matchListserv(email: ParsedEmail, listservs: MatchableListserv[]) { + const emailSignals = new Set([ + email.senderEmail, + ...email.to, + ...email.cc, + ...extractEmails(headerValue(email.headers, "list-id")), + ...extractEmails(headerValue(email.headers, "list-unsubscribe")), + ...extractEmails(headerValue(email.headers, "delivered-to")), + ]); + + for (const listserv of listservs) { + const candidates = [listserv.listEmail, ...listserv.senderEmails].map((value) => + value.toLowerCase(), + ); + if (candidates.some((candidate) => emailSignals.has(candidate))) { + return listserv._id; + } + } + + return undefined; +} + +function headerValue(headers: Array<{ name: string; value: string }>, name: string) { + return headers.find((header) => header.name.toLowerCase() === name)?.value ?? ""; +} + +function extractEmailAddress(value: string) { + const angleMatch = value.match(/<([^>]+)>/); + if (angleMatch) return normalizeEmail(angleMatch[1]); + + const bareMatch = value.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i); + if (bareMatch) return normalizeEmail(bareMatch[0]); + + return normalizeEmail(value); +} + +function extractEmails(value: string) { + const matches = value.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi); + return matches ? matches.map(normalizeEmail) : []; +} + +function normalizeEmail(value: string) { + return value.trim().toLowerCase(); +} + +function parseDate(value: string | undefined) { + if (!value) return null; + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; +} + +function parseInternalDate(value: string | undefined) { + if (!value) return null; + const ms = Number(value); + return Number.isFinite(ms) && ms > 0 ? ms : null; +} + +function extractBodies(part: GmailMessagePart | undefined) { + const out = { text: "", html: "" }; + walkPart(part, out); + if (!out.text && out.html) out.text = htmlToText(out.html); + return out; +} + +function walkPart(part: GmailMessagePart | undefined, out: { text: string; html: string }) { + if (!part) return; + + const mime = (part.mimeType ?? "").toLowerCase(); + if (part.body?.data && !part.parts?.length) { + const decoded = decodeBase64Url(part.body.data); + if (mime === "text/plain" && !out.text) out.text = decoded; + if (mime === "text/html" && !out.html) out.html = decoded; + return; + } + + for (const child of part.parts ?? []) { + walkPart(child, out); + } +} + +function htmlToText(html: string) { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(//gi, "\n") + .replace(/<\/?(p|div|section|article|header|footer|main|li|h[1-6]|blockquote|pre|table|tr|td|th)[^>]*>/gi, "\n") + .replace(/]*>([\s\S]*?)<\/a>/gi, "$1") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/ /g, " ") + .split("\n") + .map((line) => line.replace(/[ \t]+/g, " ").trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function decodeBase64Url(encoded: string) { + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + + try { + return new TextDecoder("utf-8").decode( + Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)), + ); + } catch { + return ""; + } +} + +function formatError(error: unknown) { + if (error instanceof Error) return error.message.slice(0, 500); + if (typeof error === "string") return error.slice(0, 500); + return "Unknown ingestion error."; +} diff --git a/convex/listservAdmin.ts b/convex/listservAdmin.ts new file mode 100644 index 0000000..2dc716d --- /dev/null +++ b/convex/listservAdmin.ts @@ -0,0 +1,775 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { action, internalMutation, internalQuery, mutation, query } from "./_generated/server"; +import { requireAdminToken } from "./_shared/adminToken"; +import type { Id } from "./_generated/dataModel"; +import type { ActionCtx, MutationCtx } from "./_generated/server"; + +declare const process: { env: Record }; + +const GMAIL_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"; + +type CandidateInput = { + email: string; + displayName?: string; + confidence: number; + popularity?: number; + matchedReasons: string[]; +}; + +type D1QueryResponse = { + success: boolean; + errors?: Array<{ message?: string }>; + result?: Array<{ + results?: Array<{ email?: string; popularity?: number | string }>; + }>; +}; + +type DiscoveryStats = { + inserted: number; + updated: number; +}; + +type GmailSendResponse = { + id?: string; +}; + +type IngestionRunResult = { + fetched: number; + unseen: number; + stored: number; +}; + +type GmailConnectionSnapshot = { + email: string; + refreshToken: string; +}; + +const SEED_CANDIDATES: CandidateInput[] = [ + candidate("wicc-l@list.cornell.edu", "WICC", 95, 6, [ + "list domain", + "list-style address", + "high overlap", + ]), + candidate("acsu-l@list.cornell.edu", "ACSU", 92, 5, [ + "list domain", + "list-style address", + "high overlap", + ]), + candidate("entrepreneurship-l@mm.list.cornell.edu", "Entrepreneurship", 90, 6, [ + "list domain", + "list-style address", + "high overlap", + ]), + candidate("swemail-l@list.cornell.edu", "SWE Mail", 82, 2, [ + "list domain", + "list-style address", + ]), + candidate("lindseth_climbing_wall-l@list.cornell.edu", "Lindseth Climbing Wall", 78, 3, [ + "list domain", + "list-style address", + ]), + candidate("meng-students@list.cs.cornell.edu", "MEng Students", 72, 1, [ + "list domain", + ]), + candidate("emotionkpop-l@list.cornell.edu", "E.Motion K-Pop", 70, 1, [ + "list domain", + "list-style address", + ]), + candidate("flute-l@list.cornell.edu", "Flute List", 68, 1, [ + "list domain", + "list-style address", + ]), + candidate("psc-lep-l@list.cornell.edu", "PSC LEP", 68, 1, [ + "list domain", + "list-style address", + ]), + candidate("fgssgradminors-l@list.cornell.edu", "FGSS Grad Minors", 66, 1, [ + "list domain", + "list-style address", + ]), + candidate("achresidents-l@list.cornell.edu", "ACH Residents", 54, 1, [ + "list domain", + "list-style address", + "possibly private", + ]), +]; + +export const dashboard = query({ + args: { token: v.string() }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + + const [ + candidates, + listservs, + ingestionState, + discoveryRuns, + joinAttempts, + ingestionRuns, + recentMessages, + ] = await Promise.all([ + ctx.db.query("listservCandidates").order("desc").take(150), + ctx.db.query("listservs").order("desc").take(150), + ctx.db.query("listservIngestionState").collect(), + ctx.db.query("discoveryRuns").withIndex("by_started_at").order("desc").take(12), + ctx.db.query("joinAttempts").withIndex("by_created_at").order("desc").take(20), + ctx.db.query("ingestionRuns").withIndex("by_started_at").order("desc").take(20), + ctx.db.query("listservMessages").withIndex("by_received_at").order("desc").take(20), + ]); + + return { + candidates, + listservs, + ingestionState, + discoveryRuns, + joinAttempts, + ingestionRuns, + recentMessages, + }; + }, +}); + +export const runDiscovery = action({ + args: { token: v.string() }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + + const runId: Id<"discoveryRuns"> = await ctx.runMutation( + internal.listservAdmin.startDiscoveryRun, + {}, + ); + + try { + const discovered = await discoverCandidatesFromInitialDataset(); + const stats = (await ctx.runMutation(internal.listservAdmin.upsertDiscoveredCandidates, { + candidates: discovered, + })) as DiscoveryStats; + + await ctx.runMutation(internal.listservAdmin.finishDiscoveryRun, { + runId, + status: "completed", + candidatesFound: discovered.length, + candidatesInserted: stats.inserted, + candidatesUpdated: stats.updated, + }); + + return { candidatesFound: discovered.length, ...stats }; + } catch (error) { + await ctx.runMutation(internal.listservAdmin.finishDiscoveryRun, { + runId, + status: "failed", + candidatesFound: 0, + candidatesInserted: 0, + candidatesUpdated: 0, + error: formatError(error), + }); + throw error; + } + }, +}); + +export const seedCandidates = mutation({ + args: { token: v.string() }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + return upsertCandidates(ctx, SEED_CANDIDATES); + }, +}); + +export const addCandidate = mutation({ + args: { + token: v.string(), + email: v.string(), + displayName: v.optional(v.string()), + notes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + + const email = normalizeEmail(args.email); + const existing = await ctx.db + .query("listservCandidates") + .withIndex("by_email", (q) => q.eq("email", email)) + .unique(); + + if (existing) return existing._id; + + const now = Date.now(); + return ctx.db.insert("listservCandidates", { + email, + displayName: cleanOptional(args.displayName) ?? inferDisplayName(email), + source: "manual", + status: "candidate", + confidence: 50, + matchedReasons: ["manual"], + notes: cleanOptional(args.notes), + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const rejectCandidate = mutation({ + args: { + token: v.string(), + candidateId: v.id("listservCandidates"), + notes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + await ctx.db.patch(args.candidateId, { + status: "rejected", + notes: cleanOptional(args.notes), + updatedAt: Date.now(), + }); + }, +}); + +export const approveCandidate = mutation({ + args: { + token: v.string(), + candidateId: v.id("listservCandidates"), + name: v.optional(v.string()), + joinMethod: v.optional( + v.union( + v.literal("email_command"), + v.literal("web_form"), + v.literal("manual"), + v.literal("unknown"), + ), + ), + notes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + + const candidateRow = await ctx.db.get(args.candidateId); + if (!candidateRow) throw new Error("Candidate not found."); + + const listEmail = stripOwnerPrefix(candidateRow.email); + const existing = await ctx.db + .query("listservs") + .withIndex("by_list_email", (q) => q.eq("listEmail", listEmail)) + .unique(); + + const now = Date.now(); + const listservFields = { + name: cleanOptional(args.name) ?? candidateRow.displayName ?? inferDisplayName(listEmail), + listEmail, + senderEmails: [...new Set([candidateRow.email, listEmail])], + status: "joining" as const, + joinMethod: args.joinMethod ?? ("unknown" as const), + joinStatus: "not_started" as const, + source: candidateRow.source, + candidateId: args.candidateId, + notes: cleanOptional(args.notes) ?? candidateRow.notes, + updatedAt: now, + }; + + let listservId = existing?._id; + if (existing) { + await ctx.db.patch(existing._id, listservFields); + } else { + listservId = await ctx.db.insert("listservs", { + ...listservFields, + createdAt: now, + }); + } + + await ctx.db.patch(args.candidateId, { + status: "approved", + updatedAt: now, + }); + + return listservId; + }, +}); + +export const sendJoinEmail = action({ + args: { + token: v.string(), + listservId: v.id("listservs"), + recipient: v.string(), + subject: v.string(), + body: v.string(), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + + const listserv = await ctx.runQuery(internal.listservAdmin.getListservForAdmin, { + listservId: args.listservId, + }); + if (!listserv) throw new Error("Listserv not found."); + + const recipient = cleanRequired(args.recipient, "Recipient"); + const subject = cleanRequired(args.subject, "Subject"); + const body = cleanRequired(args.body, "Body"); + + try { + const connection = await getGmailConnection(ctx); + const accessToken = await refreshGmailAccessToken(ctx, connection.refreshToken); + const sent = await sendGmailMessage(accessToken, connection.email, recipient, subject, body); + + await ctx.runMutation(internal.listservAdmin.recordJoinAttempt, { + listservId: args.listservId, + status: "sent", + recipient, + subject, + body, + gmailMessageId: sent.id, + }); + + return { gmailMessageId: sent.id }; + } catch (error) { + await ctx.runMutation(internal.listservAdmin.recordJoinAttempt, { + listservId: args.listservId, + status: "failed", + recipient, + subject, + body, + error: formatError(error), + }); + throw error; + } + }, +}); + +export const runIngestionNow = action({ + args: { token: v.string() }, + handler: async (ctx, args): Promise => { + requireAdminToken(args.token); + return (await ctx.runAction(internal.ingestion.pollListservInbox, { + trigger: "manual", + })) as IngestionRunResult; + }, +}); + +export const updateListservStatus = mutation({ + args: { + token: v.string(), + listservId: v.id("listservs"), + status: v.union( + v.literal("joining"), + v.literal("active"), + v.literal("paused"), + v.literal("failed"), + ), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + await ctx.db.patch(args.listservId, { + status: args.status, + updatedAt: Date.now(), + }); + }, +}); + +export const updateJoinStatus = mutation({ + args: { + token: v.string(), + listservId: v.id("listservs"), + joinStatus: v.union( + v.literal("not_started"), + v.literal("join_email_sent"), + v.literal("awaiting_confirmation"), + v.literal("joined"), + v.literal("failed"), + v.literal("manual_required"), + ), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + await ctx.db.patch(args.listservId, { + joinStatus: args.joinStatus, + updatedAt: Date.now(), + }); + }, +}); + +export const updateListservNotes = mutation({ + args: { + token: v.string(), + listservId: v.id("listservs"), + notes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + requireAdminToken(args.token); + await ctx.db.patch(args.listservId, { + notes: cleanOptional(args.notes), + updatedAt: Date.now(), + }); + }, +}); + +export const startDiscoveryRun = internalMutation({ + args: {}, + handler: async (ctx) => { + return ctx.db.insert("discoveryRuns", { + source: "initial_sender_dataset", + status: "running", + startedAt: Date.now(), + candidatesFound: 0, + candidatesInserted: 0, + candidatesUpdated: 0, + }); + }, +}); + +export const finishDiscoveryRun = internalMutation({ + args: { + runId: v.id("discoveryRuns"), + status: v.union(v.literal("completed"), v.literal("failed")), + candidatesFound: v.number(), + candidatesInserted: v.number(), + candidatesUpdated: v.number(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.runId, { + status: args.status, + finishedAt: Date.now(), + candidatesFound: args.candidatesFound, + candidatesInserted: args.candidatesInserted, + candidatesUpdated: args.candidatesUpdated, + error: args.error, + }); + }, +}); + +export const upsertDiscoveredCandidates = internalMutation({ + args: { + candidates: v.array( + v.object({ + email: v.string(), + displayName: v.optional(v.string()), + confidence: v.number(), + popularity: v.optional(v.number()), + matchedReasons: v.array(v.string()), + }), + ), + }, + handler: async (ctx, args) => { + return upsertCandidates(ctx, args.candidates); + }, +}); + +export const getListservForAdmin = internalQuery({ + args: { listservId: v.id("listservs") }, + handler: async (ctx, args) => { + return ctx.db.get(args.listservId); + }, +}); + +export const recordJoinAttempt = internalMutation({ + args: { + listservId: v.id("listservs"), + status: v.union(v.literal("sent"), v.literal("failed")), + recipient: v.string(), + subject: v.string(), + body: v.string(), + gmailMessageId: v.optional(v.string()), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + await ctx.db.insert("joinAttempts", { + listservId: args.listservId, + status: args.status, + recipient: args.recipient, + subject: args.subject, + body: args.body, + gmailMessageId: args.gmailMessageId, + error: args.error, + createdAt: now, + }); + + await ctx.db.patch(args.listservId, { + joinStatus: args.status === "sent" ? "join_email_sent" : "failed", + updatedAt: now, + }); + }, +}); + +async function upsertCandidates(ctx: MutationCtx, candidates: CandidateInput[]) { + const now = Date.now(); + let inserted = 0; + let updated = 0; + + for (const input of candidates) { + const email = normalizeEmail(input.email); + const existing = await ctx.db + .query("listservCandidates") + .withIndex("by_email", (q) => q.eq("email", email)) + .unique(); + + if (existing) { + if (existing.status !== "candidate") continue; + await ctx.db.patch(existing._id, { + displayName: input.displayName ?? existing.displayName ?? inferDisplayName(email), + confidence: Math.max(existing.confidence, input.confidence), + popularity: input.popularity ?? existing.popularity, + matchedReasons: [...new Set([...existing.matchedReasons, ...input.matchedReasons])], + updatedAt: now, + }); + updated += 1; + continue; + } + + await ctx.db.insert("listservCandidates", { + email, + displayName: input.displayName ?? inferDisplayName(email), + source: "d1_discovery", + status: "candidate", + confidence: input.confidence, + popularity: input.popularity, + matchedReasons: input.matchedReasons, + createdAt: now, + updatedAt: now, + }); + inserted += 1; + } + + return { inserted, updated }; +} + +async function discoverCandidatesFromInitialDataset() { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + const databaseId = process.env.CLOUDFLARE_D1_DATABASE_ID; + const token = process.env.CLOUDFLARE_API_TOKEN; + + if (!accountId || !databaseId || !token) { + throw new Error( + "Cloudflare env vars are not configured. Set CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_D1_DATABASE_ID, and CLOUDFLARE_API_TOKEN.", + ); + } + + const sql = ` + SELECT e.email, COUNT(es.user_hash) AS popularity + FROM emails e + LEFT JOIN email_submissions es ON es.email_id = e.id + WHERE + lower(e.email) LIKE '%@list.cornell.edu' + OR lower(e.email) LIKE '%@mm.list.cornell.edu' + OR lower(e.email) LIKE '%@list.cs.cornell.edu' + OR lower(substr(e.email, 1, instr(e.email, '@') - 1)) LIKE '%-l' + OR lower(substr(e.email, 1, instr(e.email, '@') - 1)) LIKE '%announce%' + OR lower(e.email) LIKE '%newsletter%' + OR lower(e.email) LIKE '%digest%' + GROUP BY e.id + ORDER BY popularity DESC, e.email + LIMIT 250 + `; + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sql }), + }, + ); + + if (!response.ok) { + throw new Error(`Cloudflare D1 query failed (${response.status}): ${await response.text()}`); + } + + const payload = (await response.json()) as D1QueryResponse; + if (!payload.success) { + throw new Error(payload.errors?.map((error) => error.message).join(", ") || "D1 query failed."); + } + + return (payload.result?.[0]?.results ?? []) + .flatMap((row) => scoreCandidate(row.email ?? "", Number(row.popularity ?? 0))) + .filter((candidateRow) => candidateRow.confidence >= 45) + .slice(0, 100); +} + +function scoreCandidate(emailValue: string, popularity: number): CandidateInput[] { + const email = normalizeEmail(emailValue); + if (!email || !email.includes("@")) return []; + if (/noreply|no-reply|daemon|notification|receipt|verify/.test(email)) return []; + + const [local = "", domain = ""] = email.split("@"); + const reasons: string[] = []; + let score = 0; + + if (["list.cornell.edu", "mm.list.cornell.edu", "list.cs.cornell.edu"].includes(domain)) { + score += 55; + reasons.push("list domain"); + } + + if (local.endsWith("-l")) { + score += 20; + reasons.push("list-style address"); + } + + if (/announce|newsletter|digest/.test(local)) { + score += 12; + reasons.push("announcement-style address"); + } + + if (popularity >= 5) { + score += 15; + reasons.push("high overlap"); + } else if (popularity >= 2) { + score += 8; + reasons.push("some overlap"); + } + + if (local.startsWith("owner-")) { + score -= 30; + reasons.push("owner/admin address"); + } + + if (!domain.endsWith("cornell.edu") && !domain.endsWith("cornellsun.com")) score -= 35; + + return [ + { + email, + displayName: inferDisplayName(email), + confidence: Math.max(0, Math.min(100, score)), + popularity, + matchedReasons: reasons.length > 0 ? reasons : ["pattern match"], + }, + ]; +} + +async function getGmailConnection(ctx: ActionCtx) { + const connection = (await ctx.runQuery( + internal.gmailConnection.getConnection, + {}, + )) as GmailConnectionSnapshot | null; + + if (!connection) { + throw new Error("Gmail is not connected. Use the admin page to connect Gmail first."); + } + + return connection; +} + +async function refreshGmailAccessToken(ctx: ActionCtx, refreshToken: string) { + const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID; + const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET; + + if (!refreshToken || !clientId || !clientSecret) { + throw new Error("Gmail OAuth client env vars are not configured."); + } + + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch(GMAIL_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + const error = `Token refresh failed (${response.status}): ${await response.text()}`; + await ctx.runMutation(internal.gmailConnection.markInvalid, { error }); + throw new Error(error); + } + + const data = (await response.json()) as { access_token?: string }; + if (!data.access_token) throw new Error("Token refresh returned no access_token."); + return data.access_token; +} + +async function sendGmailMessage( + accessToken: string, + from: string, + to: string, + subject: string, + body: string, +) { + const mime = [ + `From: ${from}`, + `To: ${to}`, + `Subject: ${subject}`, + "Content-Type: text/plain; charset=UTF-8", + "", + body, + ].join("\r\n"); + + const response = await fetch(GMAIL_SEND_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ raw: base64UrlEncode(mime) }), + }); + + if (!response.ok) { + throw new Error(`Gmail send failed (${response.status}): ${await response.text()}`); + } + + return (await response.json()) as GmailSendResponse; +} + +function candidate( + email: string, + displayName: string, + confidence: number, + popularity: number, + matchedReasons: string[], +): CandidateInput { + return { email, displayName, confidence, popularity, matchedReasons }; +} + +function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +function stripOwnerPrefix(email: string) { + const [local, domain] = normalizeEmail(email).split("@"); + if (!local || !domain) return normalizeEmail(email); + return `${local.replace(/^owner-/, "")}@${domain}`; +} + +function inferDisplayName(email: string) { + const local = email.split("@")[0] ?? email; + const trimmed = local.replace(/^owner-/, "").replace(/-l$/, ""); + return trimmed + .split(/[-_.]+/) + .filter(Boolean) + .map((part) => (part.length <= 4 ? part.toUpperCase() : capitalize(part))) + .join(" "); +} + +function capitalize(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function cleanOptional(value: string | undefined) { + const cleaned = value?.trim(); + return cleaned ? cleaned : undefined; +} + +function cleanRequired(value: string, label: string) { + const cleaned = cleanOptional(value); + if (!cleaned) throw new Error(`${label} is required.`); + return cleaned; +} + +function base64UrlEncode(value: string) { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function formatError(error: unknown) { + if (error instanceof Error) return error.message.slice(0, 500); + if (typeof error === "string") return error.slice(0, 500); + return "Unknown error."; +} diff --git a/convex/schema.ts b/convex/schema.ts index 7caca4f..3d9a499 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -16,6 +16,173 @@ export default defineSchema({ section: v.optional(v.string()), // "On-Campus", "Off-Campus", "Opportunities" }).index("by_listserv", ["listserv"]), + listservCandidates: defineTable({ + email: v.string(), + displayName: v.optional(v.string()), + source: v.union( + v.literal("d1_discovery"), + v.literal("manual"), + v.literal("import"), + ), + status: v.union( + v.literal("candidate"), + v.literal("approved"), + v.literal("rejected"), + ), + confidence: v.number(), + popularity: v.optional(v.number()), + matchedReasons: v.array(v.string()), + notes: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_email", ["email"]) + .index("by_status", ["status"]), + + listservs: defineTable({ + name: v.string(), + listEmail: v.string(), + senderEmails: v.array(v.string()), + status: v.union( + v.literal("joining"), + v.literal("active"), + v.literal("paused"), + v.literal("failed"), + ), + joinMethod: v.union( + v.literal("email_command"), + v.literal("web_form"), + v.literal("manual"), + v.literal("unknown"), + ), + joinStatus: v.union( + v.literal("not_started"), + v.literal("join_email_sent"), + v.literal("awaiting_confirmation"), + v.literal("joined"), + v.literal("failed"), + v.literal("manual_required"), + ), + source: v.union( + v.literal("d1_discovery"), + v.literal("manual"), + v.literal("import"), + ), + candidateId: v.optional(v.id("listservCandidates")), + notes: v.optional(v.string()), + lastReceivedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_list_email", ["listEmail"]) + .index("by_status", ["status"]), + + listservMessages: defineTable({ + gmailMessageId: v.string(), + threadId: v.optional(v.string()), + listservId: v.optional(v.id("listservs")), + sender: v.string(), + senderEmail: v.string(), + to: v.array(v.string()), + cc: v.array(v.string()), + subject: v.string(), + receivedAt: v.number(), + bodyText: v.string(), + bodyHtml: v.string(), + headers: v.array( + v.object({ + name: v.string(), + value: v.string(), + }), + ), + processingStatus: v.union( + v.literal("new"), + v.literal("parsed"), + v.literal("ignored"), + v.literal("failed"), + ), + createdAt: v.number(), + }) + .index("by_gmail_message_id", ["gmailMessageId"]) + .index("by_listserv", ["listservId"]) + .index("by_received_at", ["receivedAt"]) + .index("by_processing_status", ["processingStatus"]), + + listservIngestionState: defineTable({ + key: v.string(), + value: v.optional(v.string()), + status: v.union( + v.literal("idle"), + v.literal("running"), + v.literal("failed"), + ), + lastStartedAt: v.optional(v.number()), + lastSucceededAt: v.optional(v.number()), + lastError: v.optional(v.string()), + updatedAt: v.number(), + }).index("by_key", ["key"]), + + discoveryRuns: defineTable({ + source: v.literal("initial_sender_dataset"), + status: v.union( + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + ), + startedAt: v.number(), + finishedAt: v.optional(v.number()), + candidatesFound: v.number(), + candidatesInserted: v.number(), + candidatesUpdated: v.number(), + error: v.optional(v.string()), + }).index("by_started_at", ["startedAt"]), + + joinAttempts: defineTable({ + listservId: v.id("listservs"), + status: v.union(v.literal("sent"), v.literal("failed")), + recipient: v.string(), + subject: v.string(), + body: v.string(), + gmailMessageId: v.optional(v.string()), + error: v.optional(v.string()), + createdAt: v.number(), + }) + .index("by_listserv", ["listservId"]) + .index("by_created_at", ["createdAt"]), + + ingestionRuns: defineTable({ + trigger: v.union(v.literal("cron"), v.literal("manual")), + status: v.union( + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + ), + startedAt: v.number(), + finishedAt: v.optional(v.number()), + fetched: v.number(), + unseen: v.number(), + stored: v.number(), + error: v.optional(v.string()), + }).index("by_started_at", ["startedAt"]), + + gmailConnections: defineTable({ + key: v.string(), + email: v.string(), + refreshToken: v.string(), + scopes: v.array(v.string()), + status: v.union(v.literal("connected"), v.literal("invalid")), + connectedAt: v.number(), + updatedAt: v.number(), + lastError: v.optional(v.string()), + }).index("by_key", ["key"]), + + gmailOAuthStates: defineTable({ + state: v.string(), + createdAt: v.number(), + expiresAt: v.number(), + usedAt: v.optional(v.number()), + }).index("by_state", ["state"]), + // One row per listserv item events: defineTable({ listservEmailId: v.id("listservEmails"), // link to the listserv email data diff --git a/eslint.config.js b/eslint.config.js index 75d3c46..0e46173 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from "typescript-eslint"; import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(["dist"]), + globalIgnores(["dist", "convex/_generated"]), { files: ["**/*.{ts,tsx}"], extends: [ diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx index ef082fd..3530003 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,9 @@ -import "./App.css"; +import Admin from "./pages/Admin"; import DesignSystem from "./pages/DesignSystem"; function App() { + if (window.location.pathname === "/admin") return ; + return ; } diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..1f5445f --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,804 @@ +import { useEffect, useState } from "react"; +import type { ButtonHTMLAttributes, FormEvent, ReactNode } from "react"; +import { useAction, useMutation, useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import type { Doc, Id } from "../../convex/_generated/dataModel"; + +const ADMIN_TOKEN_STORAGE_KEY = "cornell_loop_admin_token"; + +type Candidate = Doc<"listservCandidates">; +type Listserv = Doc<"listservs">; +type IngestionState = Doc<"listservIngestionState">; +type DiscoveryRun = Doc<"discoveryRuns">; +type JoinAttempt = Doc<"joinAttempts">; +type IngestionRun = Doc<"ingestionRuns">; +type ListservMessage = Doc<"listservMessages">; + +type GmailConnectionStatus = { + email: string; + scopes: string[]; + status: "connected" | "invalid"; + connectedAt: number; + updatedAt: number; + lastError?: string; +} | null; + +type JoinDraft = { + listservId: Id<"listservs">; + recipient: string; + subject: string; + body: string; +}; + +export default function Admin() { + const [token, setToken] = useState(() => + localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY) ?? "", + ); + const [tokenDraft, setTokenDraft] = useState(token); + const [manualEmail, setManualEmail] = useState(""); + const [manualName, setManualName] = useState(""); + const [manualNotes, setManualNotes] = useState(""); + const [joinDraft, setJoinDraft] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const dashboard = useQuery( + api.listservAdmin.dashboard, + token ? { token } : "skip", + ); + const gmailConnection = useQuery( + api.gmailOAuth.connectionStatus, + token ? { token } : "skip", + ) as GmailConnectionStatus | undefined; + + const runDiscovery = useAction(api.listservAdmin.runDiscovery); + const runIngestionNow = useAction(api.listservAdmin.runIngestionNow); + const sendJoinEmail = useAction(api.listservAdmin.sendJoinEmail); + const seedCandidates = useMutation(api.listservAdmin.seedCandidates); + const addCandidate = useMutation(api.listservAdmin.addCandidate); + const approveCandidate = useMutation(api.listservAdmin.approveCandidate); + const rejectCandidate = useMutation(api.listservAdmin.rejectCandidate); + const updateListservStatus = useMutation(api.listservAdmin.updateListservStatus); + const updateJoinStatus = useMutation(api.listservAdmin.updateJoinStatus); + + useEffect(() => { + if (token) localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token); + }, [token]); + + const candidates: Candidate[] = dashboard?.candidates ?? []; + const listservs: Listserv[] = dashboard?.listservs ?? []; + const ingestionState: IngestionState[] = dashboard?.ingestionState ?? []; + const discoveryRuns: DiscoveryRun[] = dashboard?.discoveryRuns ?? []; + const joinAttempts: JoinAttempt[] = dashboard?.joinAttempts ?? []; + const ingestionRuns: IngestionRun[] = dashboard?.ingestionRuns ?? []; + const recentMessages: ListservMessage[] = dashboard?.recentMessages ?? []; + + const listservById = new Map(listservs.map((listserv) => [listserv._id, listserv])); + + function handleTokenSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setToken(tokenDraft.trim()); + } + + async function runAdminAction(successText: string, action: () => Promise) { + setError(null); + setMessage(null); + try { + await action(); + setMessage(successText); + } catch (err) { + setError(err instanceof Error ? err.message : "Admin action failed."); + } + } + + async function handleAddCandidate(event: FormEvent) { + event.preventDefault(); + await runAdminAction("Candidate added.", async () => { + await addCandidate({ + token, + email: manualEmail, + displayName: manualName || undefined, + notes: manualNotes || undefined, + }); + setManualEmail(""); + setManualName(""); + setManualNotes(""); + }); + } + + async function handleSendJoin(event: FormEvent) { + event.preventDefault(); + if (!joinDraft) return; + + await runAdminAction("Join email sent.", async () => { + await sendJoinEmail({ token, ...joinDraft }); + setJoinDraft(null); + }); + } + + if (!token) { + return ( +
+
+
+

+ Cornell Loop +

+

+ Admin Access +

+
+
+ + +
+
+
+ ); + } + + return ( +
+
+
+
+

+ Dev Console +

+

+ Listserv Operations +

+

+ Run discovery, approve sources, send join emails from dtiincubator@gmail.com, and inspect raw Gmail ingestion. +

+
+ +
+ + {message && {message}} + {error && {error}} + +
+ + + + +
+ + + + + runAdminAction("Discovery complete.", async () => { + await runDiscovery({ token }); + }) + } + onSeedCandidates={() => + runAdminAction("Cached candidates loaded.", async () => { + await seedCandidates({ token }); + }) + } + /> + +
+ +
+ + + + Add + +
+ + + runAdminAction("Candidate approved.", async () => { + await approveCandidate({ token, candidateId, name }); + }) + } + onReject={(candidateId) => + runAdminAction("Candidate rejected.", async () => { + await rejectCandidate({ token, candidateId }); + }) + } + /> + + + runAdminAction("Source status updated.", async () => { + await updateListservStatus({ token, listservId, status }); + }) + } + onJoinStatusChange={(listservId, joinStatus) => + runAdminAction("Join status updated.", async () => { + await updateJoinStatus({ token, listservId, joinStatus }); + }) + } + /> + + + runAdminAction("Ingestion run complete.", async () => { + await runIngestionNow({ token }); + }) + } + /> +
+
+ ); +} + +function DiscoverPanel({ + discoveryRuns, + onRunDiscovery, + onSeedCandidates, +}: { + discoveryRuns: DiscoveryRun[]; + onRunDiscovery: () => void; + onSeedCandidates: () => void; +}) { + return ( +
+
+ +
+ Run discovery + Load cached set +
+
+ +
+ ); +} + +function GmailConnectionPanel({ + token, + connection, +}: { + token: string; + connection: GmailConnectionStatus | undefined; +}) { + const convexSiteUrl = getConvexSiteUrl(); + const connectUrl = convexSiteUrl + ? `${convexSiteUrl}/gmail/oauth/start?token=${encodeURIComponent(token)}` + : ""; + + return ( +
+ +
+ {connection === undefined ? ( +

Loading Gmail status...

+ ) : connection === null ? ( +

No Gmail account connected yet.

+ ) : ( +
+
+ + {connection.email} +
+

Updated {formatDate(connection.updatedAt)}

+

Scopes: {connection.scopes.join(", ")}

+ {connection.lastError &&

{connection.lastError}

} +
+ )} +
+
+ ); +} + +function getConvexSiteUrl() { + const explicit = import.meta.env.VITE_CONVEX_SITE_URL as string | undefined; + if (explicit) return explicit.replace(/\/$/, ""); + + const convexUrl = import.meta.env.VITE_CONVEX_URL as string | undefined; + if (!convexUrl) return ""; + + return convexUrl.replace(/\/$/, "").replace(".convex.cloud", ".convex.site"); +} + +function CandidateTable({ + candidates, + onApprove, + onReject, +}: { + candidates: Candidate[]; + onApprove: (candidateId: Id<"listservCandidates">, name?: string) => void; + onReject: (candidateId: Id<"listservCandidates">) => void; +}) { + return ( +
+ +
+ + + + Email + Status + Score + Overlap + Reasons + Actions + + + + {candidates.map((candidate) => ( + + +
{candidate.displayName ?? candidate.email}
+
{candidate.email}
+
+ + {candidate.confidence} + {candidate.popularity ?? "-"} + {candidate.matchedReasons.join(", ")} + +
+ onApprove(candidate._id, candidate.displayName)}> + Approve + + onReject(candidate._id)}> + Reject + +
+
+ + ))} + +
+
+
+ ); +} + +function JoinPanel({ + listservs, + joinAttempts, + joinDraft, + onPrepareJoin, + onDraftChange, + onSendJoin, + onStatusChange, + onJoinStatusChange, +}: { + listservs: Listserv[]; + joinAttempts: JoinAttempt[]; + joinDraft: JoinDraft | null; + onPrepareJoin: (draft: JoinDraft) => void; + onDraftChange: (draft: JoinDraft | null) => void; + onSendJoin: (event: FormEvent) => void; + onStatusChange: (listservId: Id<"listservs">, status: Listserv["status"]) => void; + onJoinStatusChange: (listservId: Id<"listservs">, joinStatus: Listserv["joinStatus"]) => void; +}) { + return ( +
+ + + {joinDraft && ( +
+
+ onDraftChange({ ...joinDraft, recipient })} + required + /> + onDraftChange({ ...joinDraft, subject })} + required + /> +
+