From 69479a0bf0f559d3a515a8f1cf74765a3274e0b8 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 3 Mar 2026 10:26:59 -0500 Subject: [PATCH 01/80] Enhance tab functionality and admin panel UI Update UI for tab management, including adding/removing tabs and changes to admin panel behavior. --- tabs.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tabs.md diff --git a/tabs.md b/tabs.md new file mode 100644 index 0000000..1518bea --- /dev/null +++ b/tabs.md @@ -0,0 +1,14 @@ +Update the UI so that tabs can be added and remove from the page, and tabs will be inside of the dock/bottom bar. + +**Preliminary Changes** +- The admin panel close behavior will have to change so that an "X" appears in the top right corner to explicitly close the admin panel instead of the current clicking outside of the window to close it. + +**Primary Changes** +- There will be one permenant tab (home tab) that contains the logo.png. It cannot be removed. +- When the user clicks the unlock icon to allow for UI changes and widget resizing, a mini tab with a "+" icon will appear next to the home tab so that the user can add a tab. + - A modal window will pop up and show a series of font-awesome icons to select from, and a label field to enter a tab name. Once saved, the tab can be used. + - There will be a toggle to show or hide the tab label during the create. +- Once a tab is added, another "+" mini tab will appear next to it so another can be added. +- When the UI is locked again, the "+" mini tab and "-" red icon will be removed from view. +- When a custom tab has been added, a new picklist/drop down selection will appear next to all of the core applications to allow them to be rended on the custom tab selected. + - The picklist will show the tab label and not the icon. From ed3f0f2d1e3d9df9a0e027d8ef14d932f5aa19a7 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 3 Mar 2026 10:39:49 -0500 Subject: [PATCH 02/80] Enhance tabs.md with UI and customization details Added description for tab UI behavior and customization. --- tabs.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tabs.md b/tabs.md index 1518bea..0e018d8 100644 --- a/tabs.md +++ b/tabs.md @@ -9,6 +9,8 @@ Update the UI so that tabs can be added and remove from the page, and tabs will - A modal window will pop up and show a series of font-awesome icons to select from, and a label field to enter a tab name. Once saved, the tab can be used. - There will be a toggle to show or hide the tab label during the create. - Once a tab is added, another "+" mini tab will appear next to it so another can be added. + - The tabs can simply be pipes between them, similar to how the Chrome and Brave browsers render tabs: + - - When the UI is locked again, the "+" mini tab and "-" red icon will be removed from view. - When a custom tab has been added, a new picklist/drop down selection will appear next to all of the core applications to allow them to be rended on the custom tab selected. - The picklist will show the tab label and not the icon. From 84a7d990aebafeb75d9948a83a8e13540151cf07 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 3 Mar 2026 10:40:49 -0500 Subject: [PATCH 03/80] Add files via upload --- screenshots/Screenshot 2026-03-03 103618.png | Bin 0 -> 7772 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 screenshots/Screenshot 2026-03-03 103618.png diff --git a/screenshots/Screenshot 2026-03-03 103618.png b/screenshots/Screenshot 2026-03-03 103618.png new file mode 100644 index 0000000000000000000000000000000000000000..f7e81b71fbede0ec387b64716365b217fe732cf6 GIT binary patch literal 7772 zcmZWuby!s0w;n(dkdkghx{(+{9B}9mkP=ZEL=mJrl|i}&L68RNZt3olZjkP-85o9p zxWD-B^V~n?%&fD|oPG9LYrX5;>kZaWQy{>j!2^Ln1WJmsnjp|UZQwUI4mNPq?V5iL zd|^0hDnLQSLv-7KanJI_%NHO}S>!{M$$h}awNrfK2m%qd-+eH;ZF9{*Ao^n^*%#Vw zhWiVi??_!2`HzNSDqo-aA4y~2u8d7V3~F(Tu4cK4Z5Fz@ulBZ6{5kgZBkL~8FZQfP zCJpEJtKaIrExa1fsh^O`@>b?`7}^$}%XsHfR`+gCpFejkZ}SZ|%PZnFlAw9$2I|H8 zBx*LMFS1{6nv6;sgqH?X;Y`i7UYpm~1ZKiuhsQ8tC=S#A9zJXJV*O)vh$!d&cL?Sd zM8fsY7=vk(r&+E5Nd7vuu?!y?bm^A)(!-1zMtpcIpLo_Kh{LofbONTPqq|oGzGvku zfQf~*#!m|E3K|6aEiOXziWatZcYic{4PO(oY5#lnRXIJXhecpn1~wRix}b{I#)iTj zks^;v{;$UiG71U8@s}s%AtHsMwAIxa_4Q}hmQBttF50ZBGV1FefD>)*O<3atQ#~Td z_@Y}7pkEZH{+IN-#=jN^L(u1|GMRgwRBC2bRgotrC!ZYUZaGQ70t5To4Hf)HN&Ynt z3Qy`i9+Nq0#7c&kj*f+h+sex7#6$2tNaESEk@0aBB2RkO|2(o!0GtyRW_wV%JUOX* zc6Qd&Za6kJ<_{R|(*+#qPmI?v*q=gs5b5LJVohOD$1-^Y+?_Z~tKEOVg}P(Ef&W^z zS1m6t8m4Mz_c*(_WYpBWX1JhH&c*ru4gMKi78p0_ zj;J|0pRzPAo2NDoUBwnn#dqB7qvrT;9K{OMZIarPql@K(GIs|Eix%a?zHv`8sDhH0Us_(qL-Ruw`fB z4fIy!n+Z%dBneR!6oli2Fu}-HSC3Yg$2o>aSAVKi`$QQ*LEdWK6oE#!`q!tWJ>D{7 zHlExMN7v#oQfN}`+g>unStA)K*8{?h^sCB4B8HAbEj=ePwhARk zaCfm$a|HM6;>eq5eUEHYO(VfIY&p9*?4) zV80rI((8v}b!AE&fAM$E6{3P|PUL*D`)izX)KHrHVY)fB)AYr`fdM@5H@AO%zWNmY zwV`R2g#~<=uzQo2mpi88VA3`!YB@>#R5Vwc3m<PsC!f8dnu8Q5e zfB!|6=9g8mX=^ZW(cQJk6*79Uc=nH>2PfvU_34#`swdA8E(VqYVmE~pp>pLnE5PvP zB)E6R!Mk(9m1$L7=OsZz>~}w`!241^BbTgi$qi@A?^8=5OWGjbEf7C5R#pafo$o&F z%Gc6HF}6mC%S)%*mpzCUlNJ~9i$qTo+gn=waGNdEoD#m5BgTByC+Qn!yFr7kn;ovt z>kKlNvF}@yCi391>V;iH^RBFA+1ZJD$POzdRVA&lmjj8-AWAF#KBE)8k8i{`28|v| zf-P)g)mI#qcfZzfdhW)1u{j(T6c$4*oMR;=v$Zv*^FHg<#B>gnN+UOI5RsnofHc~u1yi(gGh`FUDvxo@!_V)H)Z0xk)An8M*O~3sb6U|bZ z)Hl!m5De|(#Y*^N9NTL}#Ya76kqeXm_=HP{87o#N79Ovc-0FAZ{-D&tvHD2S2u`T7 zdBo12sbgy`ZTi*eUGP8{{ez1rP)OwO_xOl>W#)q*P8JT~P<1(FDi^#^)CtPoAJLK*miz z^u9&;+702We%>Gjg{VC`?rs&8GA_p~^QEEky(kyup2m*JC7f$N8dW2yKM0X0P0gV6 zf=g&}83EI$8k135hY=BGf?M|$lEP|?`G`Lm6`d9JzSh1Lv<@za*FP4kKBctPo^Gz2 zq`qz9q3>47(%I-%=`ZzqQ^z9T6?qZi;^pv*xS5hwXYtlp3L>rGN0YKZJF6WcN}p}~#-)NpXpN^S?_o+U(b$JW$6IovQSbK+`>?6qz?X!JZn zKk$`aO!tR208`t#nW=3s^zElz)$u=CHXkTZ|xs@drV zxqzE~vD7oL#2gB;@DcFwaFL+F%IiqGB< zR4C3(B#0~t`P+|P-+$d;9W!$;W`-p%iW9(BZJnb-R+sG)iyRx;h{G2Cu?b6O`6km%4iJ){_u{hfI zyQOQudT>ppN6r>o?`|W?!Pc@0{`Ig94m} z+iS!y7Z%hvO(6=b*hKY1Q%h68tV>3X8``m8@3PSp3ScZnzStd#s*2j8F?%bsEc|2H zxKs6bQMb^2xn9ZQj*bpc!~qz|=*{zHBYUr??fPgT2EnI@vg`+v+PZI%%iUM3DYByY zxe5iZ>~YyDe43Gnp$#tQ%9nd|*e=zvJ2)d@Co+78ZOnpA+fw3O_ zSaH3)lRzSvd0+kj`7~l)=%g1HG?5f&Y4&;c9pJ}M-oy%Wb#v~50+_={=nuX&)3O%R zvNVKSnMO84PNa;2LcN!6q?l(aP<5tJ{Vx$npT5`yt z)RDJplGr06$s^FXmB`PxKhu-b?#S7_d-%@gs6JAxIYV)l_ra`-Nbr*RS$?5^f<~&4b;Dt-&9 zhlIxPDF+V#!5-DbVxXtysqg@xJC8Y;UX7wLTDE@cnF%K(=_Zp1>SB)5fF!2he#Usm zLo9~jZG?K}@h~#&I`cNPxSbpaX9Og{t6#5OdUlpk(5&7RZ_-$~ejj!Gm#OoB&vs)^ zdL$Y%DW9WTCXe&CPrLh%3`a^U`}_>L>x%>V#dI%FOU`}9<-sI+kY5tLZRVEZ7wh75 z2bYHMK+VIw)Zt{QNT9swRoV&Uk&==!I}s@x)!j=huX)0?1KFFar)~DUrWY1jxJ!xY zie36bqk8O3zWEtlaz_4EU!xnXTnO`_&z^zu^|@=1Fi! zzxXQfS=qvwoQ9SbV4NJm;pg#OpPi9!>fFkXZaM9>jJGRW%)w?JCr-<00#BJ*w|jkx z6JM_;HL3%-q>+B&eoP6}Xh+ZGL+?Y+w%bnY>ei6&fBzaIRa^rWJ}Vd-KZe|*9CIhE z8W1a?J$-#`1tm=(TU#Hd#R#!Z66XINObZkaAzM9i*3~#q?-IFxV~>elY2~x*c^CG8 z55i;_37pMr)#H~GrFofKSlsr_c{d>bok8-8t^BotGB^oPt2r3<9H2g@})ugjD(@Ta={RJ6Pr}YxbFl!?U2G}a7IeR(bF^9Du9Ro;dT!RamR#vb{ z*(gHq)6?(6Nt+@fT=aM-I=WxJNNxqiAct^cI!*0oB0A>~yb)zI^W-!VN@CHgj6)af z${k89uXhz1B9#(_-U_0}=PPU5ZUg5wj)COifEe-=HUp`v`d$d2iyK2qG3sh44a?V* zhMqUh4Hs9thzVNTzYC4GAtC(geyd_UMs;M7sH4Dm5i@MvqMi;|6z<7PAgh{Q-(^+% z%@D$+znlju!;U+)V`dici`_S@;}$!T%qPTS^=|;Jy{R^10VVvYgV!8+q-|FXQJh7< ze!z(N9b2;pAjR+SD-SJcc+^u%tUn7xp3i{lNBZ{)RLqTIeCDV8K!6ULt+X^iRkk^~ z-EZM0l+kp~Xy)5e<4pvMOL{d$|BozYAWT6m837LkW?qSBtYz`?gFu>E*(xObcUQWf zQX1dPinNulL2fhjayt}_>SW;@di5Ke%eR)pDKRXvlZ8H-RI4l^780hWrcq@(xTu2F zX|s{G`v_F1X-o7kKjzlufmsptWes#7$ge>L3Ev@0`JFF2G$Wz$b8|jGA4mv!H$Jo> zrPU|c?RE{Vx2B_tX8$KUJyd&Oyubs1^7YoTDx zm6&8gl|Z{E8>)?Etrpw%=i4+uP_zQ?fE8<;L~s&OFol8PFJeIiK|F42YiqHm+Zap? zB%lQw-&~z*5V9uv+hoCG88PstD()HLB0A|i zz)r$ce(=jM7ASDg;sN09@{D=y^N#Joi`&?e)~A>4J{1J-hHY|h*K;&(*OWf^)RB-* zNn*Z@q{o`IlZB9lN{3^=z+kRN^sx(c=#bcH+sqTJy!bc;I2T>6QVf>_9aT7gdZn)$ zv6^L}XymYI3WJY--~Mf%`3S(Qpx|HzxYta=HD5+%25fkT@b#%|tzPqrvjc#lsT5u; zsj%N)4mV<|90D>mvxsjmu8G!Skn^%d6nUI^@z2;_y2&aiFiS}2lc>B@iXHZNo%dG^ z;pBW&46|^CKkFT*##d9RMEaja|LN{pS;-0w!Rn|b+1k36{K?(L+$n&+=kkn%grwAQ zQ?2lA4dL!oN%wMl;43{noW#LzmZ{zpF7*qUzwr9Y)uzglI!E)|%3I2LG~rrgD#yve zk%?bvUWsx;kgY0e%5C`rvD2xp(m}zsG+`7G1!i|ePv`wHs0xO7*i-~L`y zK4}E_D%qigtYka8R*GJ1aH`aG*q@tC#pRKwO8H?AxPssm2QGtOR{cByvY0+F{y{Ke ztFO;aFHY_X?u0rvP7&bP?^HQh$mbD!LNYlwSr6gh?Y55>vwuR!2nkz4U(_J3mu@3U zny&EJ)sk8w=${>pYpI(o)H%n4ZGNtS3l8E*C=Vye*<*Uw&dg0l+5!~Sl;LN)x^>A< zZ2{}U;nwfd8t~6=VPk2L6U^R95)S0pgN22~4&r=$=mspfpt+Bq{xJd@7DkRffPfPT z@Y{{iD5NnJcD>7f+r|FeD?`HwW`zjj03fJbA zfZz-ft>bAwPkO}z3V{~!I^R!RmK#a|B5|~u2zR~UK&Dh2O zMVx_-fsnb8nIJ&~QLC-W_erA2TQAi9X6)~Z!PT4sQ83QfxM0PP3QRiSv7B){cz%}T>htwnRXXK(hlK9;@6q(#z0wY0Jy z_Tr+E!;?kib;(Kf8qcgng612L4@+-iZTxa~hd(p`)NY>GIY8VMEonG=g2^{-*m2rC zANIp&p3CWG=6fSKvGegGU(we<;UbAKGbOXdZ8RT&HY8NY=rJDHfr*TQ+kGkRMKrX> zy{FNAVDmx`sN2F!OKV51QhH2O6&LC7^V3S=)flejWfSdT}?vw zTpEhWDCp&8lf*+<*Ua*2YpJ87qfd@XS5`8B){8I=0pxm5h>VQvv6K`e1_s7oF~Toj zzKFS>uo!x5vjWXH>$z%zNGY@!0BJzKW5mtUn#vN`_QtEWGdj6n)f|z#O7Sx_fk$1E zfBp=*xM&Rt0w@`z-az#9B|Dyr?=EZKyR+IpP!J>aJS^nBk3rjGRq%MJhUrPs%#&Bh?QMkrLT?= z1eSnYTt+)o4}nMIDDUKe?IxRIE9H^Yt z*3$C#r~+u_xgFGQ-gWM*r%N$_ZVgmdH%?(SkA;mTZE;%Q7oKcb^LeS1qN-w=IUeD)dJ4vw_1uT+Zn#TyiI*cZE909vn;rwyDY=F-y6Z@nE(SY_Oga#J@*{!%9~ zFEjdnxC>xjFy&aFz0h!Tz_&Lr*jcjVg|+UR2g3SxCpb`s;pKBGX;`93b~+M*!d0Z# zRswL`R|0X+N|;ep!HXD=*HcNOkp=62*cFh%3FShU1_MqNeeJ17uKUNE=6HyD+UJNg zN!0@rR6G~jT$`NB%Uy`hd&OX3U2YHP^*K3J0tiz#2H!0c9_2O!1O<>E(9y4~suJf# zPHj5@eM3M#`75SI#n_dMM-J`j0b2P+jaRPb@6n>m2d4P#IIAqZ!E;{P+QK0rXwhu7 z8wI4fIeu6*G;7FM`uNy1^1`{}Qhv60U82Z9Okew646@Gd)7t-U?1MA#$f=7LLWat3 z0}B-RGpaW!;N_!HmBRObD$yW+88RPa2#K@3pC<^b%n0{cU$et8>M<;4av*NA%AJg<%}_L^}_w~+20SCu#$6iC9(!5n!c8vqPmBT{e(ppt4Mcu z>@&plpSg-k1t|1wf+SUOj)inTiO$aQoE$yhe-^J{A|JI6KmA|PaWx#g9d1DkZRn=8 z8Kv!i409XqnKQSuBd(k!&Yft*!N-qkR>h%a2G4;!Jg+5pHXQkp|0s~agUSbDXR-cQ z=R79 Date: Tue, 3 Mar 2026 10:42:23 -0500 Subject: [PATCH 04/80] Enhance tabs.md with screenshot and UI details Updated the tabs documentation to include a screenshot and clarify UI behavior. --- tabs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabs.md b/tabs.md index 0e018d8..91488ab 100644 --- a/tabs.md +++ b/tabs.md @@ -10,7 +10,7 @@ Update the UI so that tabs can be added and remove from the page, and tabs will - There will be a toggle to show or hide the tab label during the create. - Once a tab is added, another "+" mini tab will appear next to it so another can be added. - The tabs can simply be pipes between them, similar to how the Chrome and Brave browsers render tabs: - - + - ![tabs](https://github.com/jherforth/HomeGlow/blob/Testing/screenshots/Screenshot%202026-03-03%20103618.png?raw=true) - When the UI is locked again, the "+" mini tab and "-" red icon will be removed from view. - When a custom tab has been added, a new picklist/drop down selection will appear next to all of the core applications to allow them to be rended on the custom tab selected. - The picklist will show the tab label and not the icon. From 15b0878a42f11a28a7785fb04c59616d882e4e37 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 10 Mar 2026 12:54:59 -0400 Subject: [PATCH 05/80] Revise tab creation process and display details Updated tab creation and display behavior, including changes to modal pop-up functionality and tab visibility options. --- tabs.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tabs.md b/tabs.md index 91488ab..79ea4dc 100644 --- a/tabs.md +++ b/tabs.md @@ -4,13 +4,18 @@ Update the UI so that tabs can be added and remove from the page, and tabs will - The admin panel close behavior will have to change so that an "X" appears in the top right corner to explicitly close the admin panel instead of the current clicking outside of the window to close it. **Primary Changes** -- There will be one permenant tab (home tab) that contains the logo.png. It cannot be removed. +- There will be one permenant tab (home tab) that contains the current /client/public/HomeGlowLogo.png. It cannot be removed. - When the user clicks the unlock icon to allow for UI changes and widget resizing, a mini tab with a "+" icon will appear next to the home tab so that the user can add a tab. - - A modal window will pop up and show a series of font-awesome icons to select from, and a label field to enter a tab name. Once saved, the tab can be used. - - There will be a toggle to show or hide the tab label during the create. -- Once a tab is added, another "+" mini tab will appear next to it so another can be added. + - A modal window will pop up and show a series of font-awesome icons to select from to provide a quick visual cue as to what the tab is, and a label field to enter a tab name. + - There will be a toggle to show or hide the tab label during the create, this way the user can decide if they want only the icon to appear or both the icon and the label to render on the tab. + - Once saved in the modal pop up, the new tab will be created, and while the UI is still unlocked, the newly saved tab will then show a "-" red icon will be removed from view in the event that a user wants to remove the newly created tab. +- Once a new tab is added and saved, another "+" mini tab will appear next to it so another can be added. + - Once the UI locked again, the tab can be used. + +**Tab Display Details** - The tabs can simply be pipes between them, similar to how the Chrome and Brave browsers render tabs: - ![tabs](https://github.com/jherforth/HomeGlow/blob/Testing/screenshots/Screenshot%202026-03-03%20103618.png?raw=true) - When the UI is locked again, the "+" mini tab and "-" red icon will be removed from view. - When a custom tab has been added, a new picklist/drop down selection will appear next to all of the core applications to allow them to be rended on the custom tab selected. - The picklist will show the tab label and not the icon. + - If a tab is removed, and the widget was not previous updated in the admin panel to be put back on the main home tab, then the widget will automatically adjust and be placed on the main home tab. From 4e714973e556b8f5cc79edaf8a87f1bf823ec571 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 10 Mar 2026 13:24:32 -0400 Subject: [PATCH 06/80] Updated index.js --- client/src/app.jsx | 142 ++++++++++++--- client/src/components/AdminPanel.jsx | 145 +++++++++++++-- client/src/components/TabBar.jsx | 209 ++++++++++++++++++++++ client/src/components/TabIconModal.jsx | 192 ++++++++++++++++++++ client/src/components/WidgetContainer.jsx | 10 +- server/index.js | 192 ++++++++++++++++++++ 6 files changed, 853 insertions(+), 37 deletions(-) create mode 100644 client/src/components/TabBar.jsx create mode 100644 client/src/components/TabIconModal.jsx diff --git a/client/src/app.jsx b/client/src/app.jsx index ee59eca..fd9ad76 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -1,7 +1,7 @@ // client/src/app.jsx import React, { useState, useEffect } from 'react'; import { Container, IconButton, Box, Dialog, DialogContent } from '@mui/material'; -import { Brightness4, Brightness7, Lock, LockOpen } from '@mui/icons-material'; +import { Brightness4, Brightness7, Lock, LockOpen, Close } from '@mui/icons-material'; import SettingsIcon from '@mui/icons-material/Settings'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -13,6 +13,8 @@ import WeatherWidget from './components/WeatherWidget.jsx'; import ChoreWidget from './components/ChoreWidget.jsx'; import WidgetGallery from './components/WidgetGallery.jsx'; import WidgetContainer from './components/WidgetContainer.jsx'; +import TabBar from './components/TabBar.jsx'; +import TabIconModal from './components/TabIconModal.jsx'; import { API_BASE_URL } from './utils/apiConfig.js'; import './index.css'; @@ -48,6 +50,10 @@ const App = () => { ICS_CALENDAR_URL: '', }); const [widgetGalleryKey, setWidgetGalleryKey] = useState(0); + const [activeTab, setActiveTab] = useState(1); + const [tabs, setTabs] = useState([]); + const [widgetAssignments, setWidgetAssignments] = useState({}); + const [showTabIconModal, setShowTabIconModal] = useState(false); const refreshWidgetGallery = () => { setWidgetGalleryKey(prev => prev + 1); @@ -63,8 +69,40 @@ const App = () => { } }; fetchApiKeys(); + fetchTabs(); + fetchWidgetAssignments(); }, []); + const fetchTabs = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/api/tabs`); + setTabs(Array.isArray(response.data) ? response.data : []); + } catch (error) { + console.error('Error fetching tabs:', error); + setTabs([]); + } + }; + + const fetchWidgetAssignments = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/api/widget-assignments`); + const assignments = Array.isArray(response.data) ? response.data : []; + + const groupedAssignments = {}; + assignments.forEach(assignment => { + if (!groupedAssignments[assignment.widget_name]) { + groupedAssignments[assignment.widget_name] = []; + } + groupedAssignments[assignment.widget_name].push(assignment.tab_id); + }); + + setWidgetAssignments(groupedAssignments); + } catch (error) { + console.error('Error fetching widget assignments:', error); + setWidgetAssignments({}); + } + }; + useEffect(() => { const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); setTheme(savedTheme); @@ -112,10 +150,56 @@ const App = () => { window.location.reload(); }; + const handleTabChange = (tabId) => { + setActiveTab(tabId); + }; + + const handleAddTab = () => { + setShowTabIconModal(true); + }; + + const handleSaveTab = async (tabData) => { + try { + const response = await axios.post(`${API_BASE_URL}/api/tabs`, tabData); + await fetchTabs(); + setShowTabIconModal(false); + } catch (error) { + console.error('Error creating tab:', error); + alert('Failed to create tab. Please try again.'); + } + }; + + const handleDeleteTab = async (tabId) => { + if (!window.confirm('Are you sure you want to delete this tab? Widgets will be moved to the Home tab.')) { + return; + } + + try { + await axios.delete(`${API_BASE_URL}/api/tabs/${tabId}`); + await fetchTabs(); + await fetchWidgetAssignments(); + + if (activeTab === tabId) { + setActiveTab(1); + } + } catch (error) { + console.error('Error deleting tab:', error); + alert('Failed to delete tab. Please try again.'); + } + }; + + const isWidgetAssignedToTab = (widgetName, tabId) => { + const assignments = widgetAssignments[widgetName]; + if (!assignments || assignments.length === 0) { + return tabId === 1; + } + return assignments.includes(tabId); + }; + const buildWidgetsArray = () => { const widgets = []; - if (widgetSettings.calendar.enabled) { + if (widgetSettings.calendar.enabled && isWidgetAssignedToTab('calendar', activeTab)) { widgets.push({ id: 'calendar-widget', defaultPosition: { x: 0, y: 0 }, @@ -129,7 +213,7 @@ const App = () => { }); } - if (widgetSettings.weather.enabled) { + if (widgetSettings.weather.enabled && isWidgetAssignedToTab('weather', activeTab)) { widgets.push({ id: 'weather-widget', defaultPosition: { x: 8, y: 0 }, @@ -143,7 +227,7 @@ const App = () => { }); } - if (widgetSettings.chores.enabled) { + if (widgetSettings.chores.enabled && isWidgetAssignedToTab('chores', activeTab)) { widgets.push({ id: 'chores-widget', defaultPosition: { x: 0, y: 5 }, @@ -154,7 +238,7 @@ const App = () => { }); } - if (widgetSettings.photos.enabled) { + if (widgetSettings.photos.enabled && isWidgetAssignedToTab('photos', activeTab)) { widgets.push({ id: 'photos-widget', defaultPosition: { x: 6, y: 5 }, @@ -173,9 +257,9 @@ const App = () => { return ( <> - {widgets.length > 0 && } + {widgets.length > 0 && } - {widgetSettings.widgetGallery?.enabled && ( + {widgetSettings.widgetGallery?.enabled && activeTab === 1 && ( 0 ? 1 : 0 }}> { - + + + + @@ -212,18 +311,15 @@ const App = () => { zIndex: 1000, }} > - {/* Left: Logo */} - - HomeGlow Logo - + {/* Left: TabBar */} + {/* Center: Control Buttons */} { {/* Right: Empty space for balance */} + + setShowTabIconModal(false)} + onSave={handleSaveTab} + /> ); }; diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index 4bead58..d926419 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -38,7 +38,8 @@ import { Paper, Backdrop, RadioGroup, - Radio + Radio, + Autocomplete } from '@mui/material'; import { Delete, @@ -102,6 +103,8 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { const [pinModal, setPinModal] = useState({ open: false, mode: 'verify', title: '' }); const [isAuthenticated, setIsAuthenticated] = useState(false); const [checkingPin, setCheckingPin] = useState(true); + const [tabs, setTabs] = useState([]); + const [widgetAssignments, setWidgetAssignments] = useState({}); // Refresh interval options in milliseconds const refreshIntervalOptions = [ @@ -149,6 +152,8 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { fetchChores(); fetchPrizes(); fetchUploadedWidgets(); + fetchTabs(); + fetchWidgetAssignments(); } }, [isAuthenticated]); @@ -219,6 +224,36 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { } }; + const fetchTabs = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/api/tabs`); + setTabs(Array.isArray(response.data) ? response.data : []); + } catch (error) { + console.error('Error fetching tabs:', error); + setTabs([]); + } + }; + + const fetchWidgetAssignments = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/api/widget-assignments`); + const assignments = Array.isArray(response.data) ? response.data : []; + + const groupedAssignments = {}; + assignments.forEach(assignment => { + if (!groupedAssignments[assignment.widget_name]) { + groupedAssignments[assignment.widget_name] = []; + } + groupedAssignments[assignment.widget_name].push(assignment.tab_id); + }); + + setWidgetAssignments(groupedAssignments); + } catch (error) { + console.error('Error fetching widget assignments:', error); + setWidgetAssignments({}); + } + }; + const fetchGithubWidgets = async () => { setLoadingGithub(true); try { @@ -269,11 +304,39 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { } }; - const saveWidgetSettings = () => { - localStorage.setItem('widgetSettings', JSON.stringify(widgetSettings)); - setWidgetSettings(widgetSettings); - setSaveMessage({ show: true, type: 'success', text: 'Widget settings saved successfully! Refresh page to see changes.' }); - setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + const saveWidgetSettings = async () => { + setIsLoading(true); + try { + localStorage.setItem('widgetSettings', JSON.stringify(widgetSettings)); + setWidgetSettings(widgetSettings); + + for (const [widgetName, tabIds] of Object.entries(widgetAssignments)) { + await axios.delete(`${API_BASE_URL}/api/widget-assignments/widget/${widgetName}`); + + for (const tabId of tabIds) { + await axios.post(`${API_BASE_URL}/api/widget-assignments`, { + widget_name: widgetName, + tab_id: tabId, + }); + } + } + + setSaveMessage({ show: true, type: 'success', text: 'Widget settings saved successfully! Refresh page to see changes.' }); + setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + } catch (error) { + console.error('Error saving widget settings:', error); + setSaveMessage({ show: true, type: 'error', text: 'Failed to save widget settings. Please try again.' }); + setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + } finally { + setIsLoading(false); + } + }; + + const handleWidgetAssignmentChange = (widgetName, selectedTabIds) => { + setWidgetAssignments(prev => ({ + ...prev, + [widgetName]: selectedTabIds + })); }; const saveInterfaceSettings = () => { @@ -709,7 +772,7 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { return option ? option.label : 'Disabled'; }; - const tabs = [ + const adminTabs = [ 'APIs', 'Widgets', 'Interface', @@ -749,7 +812,7 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { setActiveTab(newValue)} sx={{ mb: 3 }}> - {tabs.map((tab, index) => ( + {adminTabs.map((tab, index) => ( ))} @@ -882,7 +945,36 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { - + + + option.label} + value={tabs.filter(tab => widgetAssignments[widget]?.includes(tab.id))} + onChange={(e, newValue) => { + handleWidgetAssignmentChange(widget, newValue.map(tab => tab.id)); + }} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + {config.refreshInterval > 0 && ( }> This widget will automatically refresh every {getRefreshIntervalLabel(config.refreshInterval).toLowerCase()} @@ -944,6 +1036,35 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { + + option.label} + value={tabs.filter(tab => widgetAssignments['weather']?.includes(tab.id))} + onChange={(e, newValue) => { + handleWidgetAssignmentChange('weather', newValue.map(tab => tab.id)); + }} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + {/* Weather Layout Mode Selection */} @@ -1027,9 +1148,9 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { 🎨 Widget Gallery - - The Widget Gallery displays custom uploaded widgets below the main dashboard widgets. - + + The Widget Gallery displays custom uploaded widgets and is only available on the Home tab. + diff --git a/client/src/components/TabBar.jsx b/client/src/components/TabBar.jsx new file mode 100644 index 0000000..7be8583 --- /dev/null +++ b/client/src/components/TabBar.jsx @@ -0,0 +1,209 @@ +import React from 'react'; +import { Box, IconButton, Tooltip } from '@mui/material'; +import { + Home, + Notifications, + Bookmark, + Business, + CalendarToday, + CameraAlt, + BarChart, + Schedule, + ChatBubble, + Assignment, + Explore, + Email, + InsertDriveFile, + Folder, + Flag, + Diamond, + PanTool, + Favorite, + AttachMoney, + Map, + Lightbulb, + Image, + Star, + Add, + Close +} from '@mui/icons-material'; + +const iconMap = { + home: Home, + bell: Notifications, + bookmark: Bookmark, + building: Business, + calendar: CalendarToday, + camera: CameraAlt, + chart: BarChart, + clock: Schedule, + chat: ChatBubble, + clipboard: Assignment, + compass: Explore, + envelope: Email, + file: InsertDriveFile, + folder: Folder, + flag: Flag, + gem: Diamond, + hand: PanTool, + heart: Favorite, + money: AttachMoney, + map: Map, + lightbulb: Lightbulb, + image: Image, + star: Star, +}; + +const TabBar = ({ tabs, activeTab, onTabChange, widgetsLocked, onAddTab, onDeleteTab }) => { + const getIconComponent = (iconName) => { + const IconComponent = iconMap[iconName] || Home; + return IconComponent; + }; + + return ( + + {tabs.map((tab, index) => { + const IconComponent = getIconComponent(tab.icon); + const isActive = activeTab === tab.id; + const isHomeTab = tab.id === 1; + + return ( + + onTabChange(tab.id)} + > + {!widgetsLocked && !isHomeTab && ( + { + e.stopPropagation(); + onDeleteTab(tab.id); + }} + sx={{ + position: 'absolute', + top: -8, + right: -8, + width: 20, + height: 20, + minWidth: 0, + padding: 0, + backgroundColor: '#ff4444', + color: 'white', + zIndex: 10, + '&:hover': { + backgroundColor: '#cc0000', + }, + }} + > + + + )} + + {isHomeTab ? ( + Home + ) : ( + <> + + {tab.show_label && ( + + {tab.label} + + )} + + )} + + + {index < tabs.length - 1 && ( + + )} + + ); + })} + + {!widgetsLocked && ( + <> + + + + + + + + )} + + ); +}; + +export default TabBar; diff --git a/client/src/components/TabIconModal.jsx b/client/src/components/TabIconModal.jsx new file mode 100644 index 0000000..4b0ec6a --- /dev/null +++ b/client/src/components/TabIconModal.jsx @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Box, + Grid, + Typography, + FormControlLabel, + Switch, + Paper, +} from '@mui/material'; +import { + Notifications, + Bookmark, + Business, + CalendarToday, + CameraAlt, + BarChart, + Schedule, + ChatBubble, + Assignment, + Explore, + Email, + InsertDriveFile, + Folder, + Flag, + Diamond, + PanTool, + Favorite, + AttachMoney, + Map, + Lightbulb, + Image, + Star, +} from '@mui/icons-material'; + +const availableIcons = [ + { name: 'bell', icon: Notifications, label: 'Bell' }, + { name: 'bookmark', icon: Bookmark, label: 'Bookmark' }, + { name: 'building', icon: Business, label: 'Building' }, + { name: 'calendar', icon: CalendarToday, label: 'Calendar' }, + { name: 'camera', icon: CameraAlt, label: 'Camera' }, + { name: 'chart', icon: BarChart, label: 'Chart' }, + { name: 'clock', icon: Schedule, label: 'Clock' }, + { name: 'chat', icon: ChatBubble, label: 'Chat' }, + { name: 'clipboard', icon: Assignment, label: 'Clipboard' }, + { name: 'compass', icon: Explore, label: 'Compass' }, + { name: 'envelope', icon: Email, label: 'Envelope' }, + { name: 'file', icon: InsertDriveFile, label: 'File' }, + { name: 'folder', icon: Folder, label: 'Folder' }, + { name: 'flag', icon: Flag, label: 'Flag' }, + { name: 'gem', icon: Diamond, label: 'Gem' }, + { name: 'hand', icon: PanTool, label: 'Hand' }, + { name: 'heart', icon: Favorite, label: 'Heart' }, + { name: 'money', icon: AttachMoney, label: 'Money' }, + { name: 'map', icon: Map, label: 'Map' }, + { name: 'lightbulb', icon: Lightbulb, label: 'Lightbulb' }, + { name: 'image', icon: Image, label: 'Image' }, + { name: 'star', icon: Star, label: 'Star' }, +]; + +const TabIconModal = ({ open, onClose, onSave }) => { + const [label, setLabel] = useState(''); + const [selectedIcon, setSelectedIcon] = useState('star'); + const [showLabel, setShowLabel] = useState(true); + const [error, setError] = useState(''); + + const handleSave = () => { + if (!label.trim()) { + setError('Tab label is required'); + return; + } + + if (label.length > 20) { + setError('Tab label must be 20 characters or less'); + return; + } + + onSave({ + label: label.trim(), + icon: selectedIcon, + show_label: showLabel, + }); + + setLabel(''); + setSelectedIcon('star'); + setShowLabel(true); + setError(''); + }; + + const handleClose = () => { + setLabel(''); + setSelectedIcon('star'); + setShowLabel(true); + setError(''); + onClose(); + }; + + return ( + + + + Create New Tab + + + + + { + setLabel(e.target.value); + setError(''); + }} + error={!!error} + helperText={error || `${label.length}/20 characters`} + sx={{ mb: 3 }} + autoFocus + /> + + setShowLabel(e.target.checked)} + /> + } + label="Show label on tab" + sx={{ mb: 3 }} + /> + + + Select an Icon + + + + {availableIcons.map((iconItem) => { + const IconComponent = iconItem.icon; + const isSelected = selectedIcon === iconItem.name; + + return ( + + setSelectedIcon(iconItem.name)} + > + + + {iconItem.label} + + + + ); + })} + + + + + + + + + ); +}; + +export default TabIconModal; diff --git a/client/src/components/WidgetContainer.jsx b/client/src/components/WidgetContainer.jsx index f444607..c1f84ec 100644 --- a/client/src/components/WidgetContainer.jsx +++ b/client/src/components/WidgetContainer.jsx @@ -10,7 +10,7 @@ import CountdownCircle from './CountdownCircle'; * Container component that manages multiple draggable widgets * Provides a responsive grid system for optimal layout */ -const WidgetContainer = ({ children, widgets = [], locked = true, onLayoutChange: onLayoutChangeCallback }) => { +const WidgetContainer = ({ children, widgets = [], locked = true, onLayoutChange: onLayoutChangeCallback, activeTab = 1 }) => { const [containerWidth, setContainerWidth] = useState(1200); const [gridCols, setGridCols] = useState(12); const [selectedWidget, setSelectedWidget] = useState(null); @@ -46,7 +46,7 @@ const WidgetContainer = ({ children, widgets = [], locked = true, onLayoutChange // Initialize layout from localStorage or defaults (only on mount or when widgets change) useEffect(() => { const initialLayout = widgets.map((widget) => { - const savedLayout = localStorage.getItem(`widget-layout-${widget.id}`); + const savedLayout = localStorage.getItem(`widget-layout-${activeTab}-${widget.id}`); if (savedLayout) { const parsed = JSON.parse(savedLayout); return { @@ -72,7 +72,7 @@ const WidgetContainer = ({ children, widgets = [], locked = true, onLayoutChange }; }); setLayout(initialLayout); - }, [widgets]); + }, [widgets, activeTab]); // Update static property when lock state changes (without recreating entire layout) useEffect(() => { @@ -118,7 +118,7 @@ const WidgetContainer = ({ children, widgets = [], locked = true, onLayoutChange w: item.w, h: item.h, }; - localStorage.setItem(`widget-layout-${item.i}`, JSON.stringify(layoutData)); + localStorage.setItem(`widget-layout-${activeTab}-${item.i}`, JSON.stringify(layoutData)); }); // Notify parent component of layout change @@ -200,7 +200,7 @@ const WidgetContainer = ({ children, widgets = [], locked = true, onLayoutChange w: updatedItem.w, h: updatedItem.h, }; - localStorage.setItem(`widget-layout-${item.i}`, JSON.stringify(layoutData)); + localStorage.setItem(`widget-layout-${activeTab}-${item.i}`, JSON.stringify(layoutData)); return updatedItem; } diff --git a/server/index.js b/server/index.js index c0e5e9a..d2b8e91 100644 --- a/server/index.js +++ b/server/index.js @@ -639,7 +639,38 @@ async function initializeDatabase() { created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS tabs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + icon TEXT NOT NULL, + show_label INTEGER DEFAULT 1, + order_position INTEGER NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS widget_tab_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + widget_name TEXT NOT NULL, + tab_id INTEGER NOT NULL, + layout_x INTEGER, + layout_y INTEGER, + layout_w INTEGER, + layout_h INTEGER, + FOREIGN KEY (tab_id) REFERENCES tabs(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_widget_tab_assignments_widget ON widget_tab_assignments(widget_name); + CREATE INDEX IF NOT EXISTS idx_widget_tab_assignments_tab ON widget_tab_assignments(tab_id); `); + + // Insert default home tab if it doesn't exist + try { + const homeTab = newDb.prepare('SELECT id FROM tabs WHERE id = 1').get(); + if (!homeTab) { + newDb.prepare('INSERT INTO tabs (id, label, icon, show_label, order_position) VALUES (?, ?, ?, ?, ?)').run(1, 'Home', 'home', 1, 0); + console.log('Default home tab created'); + } + } catch (error) { + console.error('Error creating default home tab:', error); + } // Migration: Add repeat_type column if it doesn't exist and remove old repeats column try { @@ -1995,6 +2026,167 @@ fastify.post('/api/settings', async (request, reply) => { } }); +// Tabs API Endpoints +fastify.get('/api/tabs', async (request, reply) => { + try { + const tabs = db.prepare('SELECT * FROM tabs ORDER BY order_position ASC').all(); + return tabs; + } catch (error) { + console.error('Error fetching tabs:', error); + reply.status(500).send({ error: 'Failed to fetch tabs' }); + } +}); + +fastify.post('/api/tabs', async (request, reply) => { + const { label, icon, show_label } = request.body; + + if (!label || !icon) { + return reply.status(400).send({ error: 'Label and icon are required' }); + } + + try { + const maxOrder = db.prepare('SELECT MAX(order_position) as max FROM tabs').get(); + const orderPosition = (maxOrder.max || 0) + 1; + + const stmt = db.prepare('INSERT INTO tabs (label, icon, show_label, order_position) VALUES (?, ?, ?, ?)'); + const result = stmt.run(label, icon, show_label ? 1 : 0, orderPosition); + + const newTab = db.prepare('SELECT * FROM tabs WHERE id = ?').get(result.lastInsertRowid); + return newTab; + } catch (error) { + console.error('Error creating tab:', error); + reply.status(500).send({ error: 'Failed to create tab' }); + } +}); + +fastify.patch('/api/tabs/:id', async (request, reply) => { + const { id } = request.params; + const { label, icon, show_label } = request.body; + + if (parseInt(id) === 1) { + return reply.status(400).send({ error: 'Cannot modify home tab' }); + } + + try { + const updates = []; + const values = []; + + if (label !== undefined) { + updates.push('label = ?'); + values.push(label); + } + if (icon !== undefined) { + updates.push('icon = ?'); + values.push(icon); + } + if (show_label !== undefined) { + updates.push('show_label = ?'); + values.push(show_label ? 1 : 0); + } + + if (updates.length === 0) { + return reply.status(400).send({ error: 'No fields to update' }); + } + + values.push(id); + const stmt = db.prepare(`UPDATE tabs SET ${updates.join(', ')} WHERE id = ?`); + stmt.run(...values); + + const updatedTab = db.prepare('SELECT * FROM tabs WHERE id = ?').get(id); + return updatedTab; + } catch (error) { + console.error('Error updating tab:', error); + reply.status(500).send({ error: 'Failed to update tab' }); + } +}); + +fastify.delete('/api/tabs/:id', async (request, reply) => { + const { id } = request.params; + + if (parseInt(id) === 1) { + return reply.status(400).send({ error: 'Cannot delete home tab' }); + } + + try { + const widgetAssignments = db.prepare('SELECT * FROM widget_tab_assignments WHERE tab_id = ?').all(id); + + if (widgetAssignments.length > 0) { + const updateStmt = db.prepare('UPDATE widget_tab_assignments SET tab_id = 1 WHERE tab_id = ?'); + updateStmt.run(id); + } + + const deleteStmt = db.prepare('DELETE FROM tabs WHERE id = ?'); + deleteStmt.run(id); + + return { success: true, message: 'Tab deleted successfully' }; + } catch (error) { + console.error('Error deleting tab:', error); + reply.status(500).send({ error: 'Failed to delete tab' }); + } +}); + +// Widget Tab Assignments API Endpoints +fastify.get('/api/widget-assignments', async (request, reply) => { + try { + const assignments = db.prepare('SELECT * FROM widget_tab_assignments').all(); + return assignments; + } catch (error) { + console.error('Error fetching widget assignments:', error); + reply.status(500).send({ error: 'Failed to fetch widget assignments' }); + } +}); + +fastify.post('/api/widget-assignments', async (request, reply) => { + const { widget_name, tab_id } = request.body; + + if (!widget_name || !tab_id) { + return reply.status(400).send({ error: 'widget_name and tab_id are required' }); + } + + try { + const existing = db.prepare('SELECT id FROM widget_tab_assignments WHERE widget_name = ? AND tab_id = ?').get(widget_name, tab_id); + + if (existing) { + return reply.status(400).send({ error: 'Assignment already exists' }); + } + + const stmt = db.prepare('INSERT INTO widget_tab_assignments (widget_name, tab_id) VALUES (?, ?)'); + const result = stmt.run(widget_name, tab_id); + + const newAssignment = db.prepare('SELECT * FROM widget_tab_assignments WHERE id = ?').get(result.lastInsertRowid); + return newAssignment; + } catch (error) { + console.error('Error creating widget assignment:', error); + reply.status(500).send({ error: 'Failed to create widget assignment' }); + } +}); + +fastify.delete('/api/widget-assignments/:id', async (request, reply) => { + const { id } = request.params; + + try { + const stmt = db.prepare('DELETE FROM widget_tab_assignments WHERE id = ?'); + stmt.run(id); + return { success: true, message: 'Assignment deleted successfully' }; + } catch (error) { + console.error('Error deleting widget assignment:', error); + reply.status(500).send({ error: 'Failed to delete widget assignment' }); + } +}); + +fastify.delete('/api/widget-assignments/widget/:widgetName', async (request, reply) => { + const { widgetName } = request.params; + + try { + const stmt = db.prepare('DELETE FROM widget_tab_assignments WHERE widget_name = ?'); + stmt.run(widgetName); + return { success: true, message: 'Widget assignments deleted successfully' }; + } catch (error) { + console.error('Error deleting widget assignments:', error); + reply.status(500).send({ error: 'Failed to delete widget assignments' }); + } +}); + // DEBUG: Specific endpoint to test API key saving fastify.post('/api/test-api-key', async (request, reply) => { const { apiKey } = request.body; From fd811c6312bc7ded8a73ae01f48736d7e355d5a7 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 10 Mar 2026 13:46:20 -0400 Subject: [PATCH 07/80] Updated index.js --- client/src/components/AdminPanel.jsx | 61 ++++++++++++++++++---------- server/index.js | 31 +++++++++++--- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index d926419..9b170c6 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -598,28 +598,43 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { const handleProfilePictureUpload = async (userId, event) => { const file = event.target.files[0]; - if (!file) return; + if (!file) { + console.log('No file selected'); + return; + } + + console.log('File selected:', file.name, 'Size:', file.size, 'Type:', file.type); const formData = new FormData(); formData.append('file', file); try { setIsLoading(true); + console.log(`Uploading picture for user ${userId}...`); + const response = await axios.post( `${API_BASE_URL}/api/users/${userId}/upload-picture`, formData, { headers: { 'Content-Type': 'multipart/form-data' } } ); - fetchUsers(); + + console.log('Upload response:', response.data); + + await fetchUsers(); + console.log('Users fetched after upload'); } catch (error) { console.error('Error uploading profile picture:', error); + console.error('Error details:', error.response?.data); alert('Failed to upload profile picture. Please try again.'); } finally { setIsLoading(false); + event.target.value = ''; } }; - const renderUserAvatar = (user) => { + const UserAvatar = ({ user }) => { + const [imageError, setImageError] = useState(false); + let imageUrl = null; if (user.profile_picture) { if (user.profile_picture.startsWith('data:')) { @@ -629,23 +644,27 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { } } - return imageUrl ? ( - {user.username} { - e.target.style.display = 'none'; - e.target.nextSibling.style.display = 'flex'; - }} - /> - ) : ( + if (imageUrl && !imageError) { + return ( + {user.username} { + console.error('Failed to load image:', imageUrl); + setImageError(true); + }} + /> + ); + } + + return ( {user.username.charAt(0).toUpperCase()} @@ -1324,7 +1343,7 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { - {renderUserAvatar(user)} + + )} diff --git a/server/index.js b/server/index.js index c9b95cd..d5414cb 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,10 @@ const cron = require('node-cron'); // For widget upload and registry const widgetRegistryPath = path.join(__dirname, 'widgets_registry.json'); +// Calendar sync service +const CalendarSyncService = require('./services/calendarSync'); +let calendarSyncService = null; + // GitHub API configuration const GITHUB_REPO_OWNER = 'jherforth'; const GITHUB_REPO_NAME = 'HomeGlowPlugins'; @@ -2521,6 +2525,11 @@ fastify.post('/api/calendar-sources', async (request, reply) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); const info = stmt.run(name, type, url, username || null, encryptedPassword, color || '#6e44ff', 1, nextOrder); + + if (calendarSyncService) { + calendarSyncService.onSourceCreated(info.lastInsertRowid); + } + return { id: info.lastInsertRowid, success: true }; } catch (error) { console.error('Error adding calendar source:', error); @@ -2570,6 +2579,15 @@ fastify.patch('/api/calendar-sources/:id', async (request, reply) => { if (info.changes === 0) { return reply.status(404).send({ error: 'Calendar source not found' }); } + + if (calendarSyncService) { + if (enabled !== undefined) { + calendarSyncService.onSourceToggled(parseInt(id), enabled); + } else { + calendarSyncService.onSourceUpdated(parseInt(id)); + } + } + return { success: true, message: 'Calendar source updated successfully' }; } catch (error) { console.error('Error updating calendar source:', error); @@ -2580,6 +2598,10 @@ fastify.patch('/api/calendar-sources/:id', async (request, reply) => { fastify.delete('/api/calendar-sources/:id', async (request, reply) => { const { id } = request.params; try { + if (calendarSyncService) { + calendarSyncService.onSourceDeleted(parseInt(id)); + } + const stmt = db.prepare('DELETE FROM calendar_sources WHERE id = ?'); const info = stmt.run(id); if (info.changes === 0) { @@ -2626,110 +2648,115 @@ fastify.post('/api/calendar-sources/:id/test', async (request, reply) => { }); -// Enhanced endpoint to fetch and parse calendar events from multiple sources +// Get calendar events from cache (fast) fastify.get('/api/calendar-events', async (request, reply) => { try { - const sources = db.prepare('SELECT * FROM calendar_sources WHERE enabled = 1 ORDER BY sort_order, id').all(); + const { start, end } = request.query; - function normalizeAllDayEnd(end) { - // iCal all-day event ends are exclusive (DTEND is the day after the last day). - // Subtract 1 day so the end represents the actual last day of the event. - const d = new Date(end); - d.setDate(d.getDate() - 1); - return d; + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); } - function makeEvent({ item, source }) { - const isAllDay = item.start?.dateOnly ?? item.event?.start?.dateOnly ?? false; - const rawEnd = item.end ?? item.event.end; - return { - id: item.uid ?? item.event?.uid ?? null, - title: item.summary ?? item.event?.summary ?? null, - start: item.start ?? item.event.start, - end: isAllDay ? normalizeAllDayEnd(rawEnd) : rawEnd, - description: item.description ?? item.event?.description ?? null, - location: item.location ?? item.event?.location ?? null, - all_day: isAllDay, - source_id: source.id, - source_name: source.name, - source_color: source.color - }; + const events = calendarSyncService.getCachedEvents(start, end); + return events; + } catch (error) { + console.error('Error fetching calendar events:', error); + reply.status(500).send({ error: 'Failed to fetch calendar events.' }); + } +}); + +// Get calendar sync status for all sources +fastify.get('/api/calendar-sync/status', async (request, reply) => { + try { + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); } + const status = calendarSyncService.getSyncStatus(); + return status; + } catch (error) { + console.error('Error fetching sync status:', error); + reply.status(500).send({ error: 'Failed to fetch sync status' }); + } +}); - if (sources.length === 0) { - return []; +// Get sync status for a specific source +fastify.get('/api/calendar-sync/status/:sourceId', async (request, reply) => { + try { + const { sourceId } = request.params; + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); } + const status = calendarSyncService.getSyncStatus(parseInt(sourceId)); + return status || { source_id: sourceId, last_sync_at: null, last_sync_status: 'never' }; + } catch (error) { + console.error('Error fetching sync status:', error); + reply.status(500).send({ error: 'Failed to fetch sync status' }); + } +}); - const fetchPromises = sources.map(async (source) => { - try { - if (source.type === 'ICS') { - const events = await node_ical.async.fromURL(source.url); - let out = []; - for (const event of Object.values(events)) { - if (event.type === "VEVENT") { - // doing ~13 months forward and backward - const instances = node_ical.expandRecurringEvent(event, { - from: new Date(Date.now() - 13 * 30 * 24 * 60 * 60 * 1000), - to: new Date(Date.now() + 13 * 30 * 24 * 60 * 60 * 1000) - }); - - // If solo event, append and go to next event - if (!instances) { - out.push(makeEvent({ item: event, source })); - continue; - } - instances.forEach(instance => { - out.push(makeEvent({ item: instance, source })); - }); - } - }; - return out; +// Trigger manual sync for a specific source +fastify.post('/api/calendar-sync/:sourceId', async (request, reply) => { + try { + const { sourceId } = request.params; + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); + } + const result = await calendarSyncService.syncSource(parseInt(sourceId)); + return result; + } catch (error) { + console.error('Error syncing calendar source:', error); + reply.status(500).send({ error: 'Failed to sync calendar source' }); + } +}); - } else if (source.type === 'CalDAV') { - const decryptedPassword = decryptPassword(source.password); - const authHeader = 'Basic ' + Buffer.from(`${source.username}:${decryptedPassword}`).toString('base64'); +// Trigger manual sync for all sources +fastify.post('/api/calendar-sync/all', async (request, reply) => { + try { + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); + } + const results = await calendarSyncService.syncAllSources(); + return results; + } catch (error) { + console.error('Error syncing all calendar sources:', error); + reply.status(500).send({ error: 'Failed to sync calendar sources' }); + } +}); - const response = await axios.get(source.url, { - headers: { 'Authorization': authHeader }, - timeout: 15000 - }); +// Set sync interval for a specific source +fastify.patch('/api/calendar-sync/:sourceId/interval', async (request, reply) => { + try { + const { sourceId } = request.params; + const { interval_minutes } = request.body; - const icsData = response.data; - const jcalData = ICAL.parse(icsData); - const comp = new ICAL.Component(jcalData); - const vevents = comp.getAllSubcomponents('vevent'); - - return vevents.map(vevent => { - const event = new ICAL.Event(vevent); - const isAllDay = vevent.getFirstPropertyValue('dtstart').isDate || false; - const rawEnd = event.endDate.toJSDate(); - return { - id: event.uid || Math.random().toString(), - title: event.summary, - start: event.startDate.toJSDate(), - end: isAllDay ? normalizeAllDayEnd(rawEnd) : rawEnd, - description: event.description, - location: event.location, - all_day: isAllDay, - source_id: source.id, - source_name: source.name, - source_color: source.color - }; - }); - } - } catch (error) { - console.error(`Error fetching calendar from source ${source.name}:`, error.message); - return []; - } - }); + if (interval_minutes === undefined || interval_minutes < 0) { + return reply.status(400).send({ error: 'interval_minutes must be a non-negative number' }); + } - const results = await Promise.all(fetchPromises); - const allEvents = results.flat(); + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); + } - return allEvents.sort((a, b) => new Date(a.start) - new Date(b.start)); + calendarSyncService.setSyncInterval(parseInt(sourceId), interval_minutes); + return { success: true, message: `Sync interval set to ${interval_minutes} minutes` }; } catch (error) { - console.error('Error fetching calendar events:', error); - reply.status(500).send({ error: 'Failed to fetch calendar events.' }); + console.error('Error setting sync interval:', error); + reply.status(500).send({ error: 'Failed to set sync interval' }); + } +}); + +// Get sync interval for a specific source +fastify.get('/api/calendar-sync/:sourceId/interval', async (request, reply) => { + try { + const { sourceId } = request.params; + if (!calendarSyncService) { + return reply.status(503).send({ error: 'Calendar sync service not initialized' }); + } + const interval = calendarSyncService.getSyncInterval(parseInt(sourceId)); + return { source_id: parseInt(sourceId), interval_minutes: interval }; + } catch (error) { + console.error('Error getting sync interval:', error); + reply.status(500).send({ error: 'Failed to get sync interval' }); } }); @@ -3097,6 +3124,11 @@ const start = async () => { await fs.mkdir(usersDir, { recursive: true }); console.log('Uploads directories created'); + // Initialize calendar sync service + calendarSyncService = new CalendarSyncService(db, decryptPassword); + calendarSyncService.initialize(); + console.log('Calendar sync service started'); + await fastify.listen({ port: process.env.PORT || 5000, host: '0.0.0.0' }); console.log(`Server running on port ${process.env.PORT || 5000}`); } catch (err) { diff --git a/server/services/calendarSync.js b/server/services/calendarSync.js new file mode 100644 index 0000000..bfba76e --- /dev/null +++ b/server/services/calendarSync.js @@ -0,0 +1,384 @@ +const axios = require('axios'); +const ICAL = require('ical.js'); +const node_ical = require('node-ical'); + +class CalendarSyncService { + constructor(db, decryptPassword) { + this.db = db; + this.decryptPassword = decryptPassword; + this.syncIntervals = new Map(); + this.isSyncing = new Map(); + } + + initialize() { + this.ensureCacheTablesExist(); + this.startAllSyncJobs(); + console.log('Calendar sync service initialized'); + } + + ensureCacheTablesExist() { + this.db.exec(` + CREATE TABLE IF NOT EXISTS calendar_events_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + event_uid TEXT NOT NULL, + title TEXT, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + description TEXT, + location TEXT, + all_day INTEGER DEFAULT 0, + raw_data TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_id) REFERENCES calendar_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, event_uid, start_time) + ); + + CREATE INDEX IF NOT EXISTS idx_cache_source_id ON calendar_events_cache(source_id); + CREATE INDEX IF NOT EXISTS idx_cache_start_time ON calendar_events_cache(start_time); + CREATE INDEX IF NOT EXISTS idx_cache_end_time ON calendar_events_cache(end_time); + + CREATE TABLE IF NOT EXISTS calendar_sync_status ( + source_id INTEGER PRIMARY KEY, + last_sync_at TEXT, + last_sync_status TEXT, + last_sync_message TEXT, + event_count INTEGER DEFAULT 0, + sync_interval_minutes INTEGER DEFAULT 15, + FOREIGN KEY (source_id) REFERENCES calendar_sources(id) ON DELETE CASCADE + ); + `); + } + + normalizeAllDayEnd(end) { + const d = new Date(end); + d.setDate(d.getDate() - 1); + return d; + } + + async syncSource(sourceId) { + if (this.isSyncing.get(sourceId)) { + console.log(`Sync already in progress for source ${sourceId}, skipping`); + return { skipped: true }; + } + + this.isSyncing.set(sourceId, true); + + try { + const source = this.db.prepare('SELECT * FROM calendar_sources WHERE id = ? AND enabled = 1').get(sourceId); + if (!source) { + console.log(`Source ${sourceId} not found or disabled`); + return { success: false, error: 'Source not found or disabled' }; + } + + console.log(`Starting sync for calendar source: ${source.name} (${source.type})`); + const startTime = Date.now(); + + let events = []; + + if (source.type === 'ICS') { + events = await this.fetchICSEvents(source); + } else if (source.type === 'CalDAV') { + events = await this.fetchCalDAVEvents(source); + } + + this.db.prepare('DELETE FROM calendar_events_cache WHERE source_id = ?').run(sourceId); + + const insertStmt = this.db.prepare(` + INSERT OR REPLACE INTO calendar_events_cache + (source_id, event_uid, title, start_time, end_time, description, location, all_day, raw_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const insertMany = this.db.transaction((events) => { + for (const event of events) { + insertStmt.run( + sourceId, + event.uid, + event.title, + event.start.toISOString(), + event.end.toISOString(), + event.description || null, + event.location || null, + event.all_day ? 1 : 0, + JSON.stringify(event.raw || {}) + ); + } + }); + + insertMany(events); + + const duration = Date.now() - startTime; + + this.db.prepare(` + INSERT OR REPLACE INTO calendar_sync_status (source_id, last_sync_at, last_sync_status, last_sync_message, event_count) + VALUES (?, datetime('now'), 'success', ?, ?) + `).run(sourceId, `Synced ${events.length} events in ${duration}ms`, events.length); + + console.log(`Synced ${events.length} events for ${source.name} in ${duration}ms`); + + return { success: true, eventCount: events.length, duration }; + } catch (error) { + console.error(`Error syncing calendar source ${sourceId}:`, error.message); + + this.db.prepare(` + INSERT OR REPLACE INTO calendar_sync_status (source_id, last_sync_at, last_sync_status, last_sync_message) + VALUES (?, datetime('now'), 'error', ?) + `).run(sourceId, error.message); + + return { success: false, error: error.message }; + } finally { + this.isSyncing.set(sourceId, false); + } + } + + async fetchICSEvents(source) { + const events = await node_ical.async.fromURL(source.url); + const out = []; + + const rangeStart = new Date(Date.now() - 13 * 30 * 24 * 60 * 60 * 1000); + const rangeEnd = new Date(Date.now() + 13 * 30 * 24 * 60 * 60 * 1000); + + for (const event of Object.values(events)) { + if (event.type !== 'VEVENT') continue; + + const instances = node_ical.expandRecurringEvent(event, { + from: rangeStart, + to: rangeEnd + }); + + if (!instances) { + const isAllDay = event.start?.dateOnly ?? false; + const rawEnd = event.end; + out.push({ + uid: event.uid || `${source.id}-${Date.now()}-${Math.random()}`, + title: event.summary || 'Untitled Event', + start: new Date(event.start), + end: isAllDay ? this.normalizeAllDayEnd(rawEnd) : new Date(rawEnd), + description: event.description, + location: event.location, + all_day: isAllDay, + raw: { rrule: event.rrule } + }); + continue; + } + + for (const instance of instances) { + const isAllDay = instance.start?.dateOnly ?? instance.event?.start?.dateOnly ?? false; + const rawEnd = instance.end ?? instance.event?.end; + out.push({ + uid: `${instance.uid ?? instance.event?.uid ?? source.id}-${new Date(instance.start ?? instance.event?.start).getTime()}`, + title: instance.summary ?? instance.event?.summary ?? 'Untitled Event', + start: new Date(instance.start ?? instance.event?.start), + end: isAllDay ? this.normalizeAllDayEnd(rawEnd) : new Date(rawEnd), + description: instance.description ?? instance.event?.description, + location: instance.location ?? instance.event?.location, + all_day: isAllDay, + raw: { recurring: true } + }); + } + } + + return out; + } + + async fetchCalDAVEvents(source) { + const decryptedPassword = this.decryptPassword(source.password); + const authHeader = 'Basic ' + Buffer.from(`${source.username}:${decryptedPassword}`).toString('base64'); + + const response = await axios.get(source.url, { + headers: { 'Authorization': authHeader }, + timeout: 15000 + }); + + const icsData = response.data; + const jcalData = ICAL.parse(icsData); + const comp = new ICAL.Component(jcalData); + const vevents = comp.getAllSubcomponents('vevent'); + + return vevents.map(vevent => { + const event = new ICAL.Event(vevent); + const dtstart = vevent.getFirstPropertyValue('dtstart'); + const isAllDay = dtstart?.isDate ?? false; + const rawEnd = event.endDate.toJSDate(); + + return { + uid: event.uid || `${source.id}-${Date.now()}-${Math.random()}`, + title: event.summary || 'Untitled Event', + start: event.startDate.toJSDate(), + end: isAllDay ? this.normalizeAllDayEnd(rawEnd) : rawEnd, + description: event.description, + location: event.location, + all_day: isAllDay, + raw: {} + }; + }); + } + + async syncAllSources() { + const sources = this.db.prepare('SELECT id FROM calendar_sources WHERE enabled = 1').all(); + const results = []; + + for (const source of sources) { + const result = await this.syncSource(source.id); + results.push({ sourceId: source.id, ...result }); + } + + return results; + } + + getCachedEvents(startDate, endDate) { + const sources = this.db.prepare(` + SELECT id, name, color FROM calendar_sources WHERE enabled = 1 + `).all(); + + const sourceMap = new Map(sources.map(s => [s.id, s])); + + let query = ` + SELECT * FROM calendar_events_cache + WHERE source_id IN (SELECT id FROM calendar_sources WHERE enabled = 1) + `; + const params = []; + + if (startDate) { + query += ' AND end_time >= ?'; + params.push(new Date(startDate).toISOString()); + } + if (endDate) { + query += ' AND start_time <= ?'; + params.push(new Date(endDate).toISOString()); + } + + query += ' ORDER BY start_time ASC'; + + const rows = this.db.prepare(query).all(...params); + + return rows.map(row => { + const source = sourceMap.get(row.source_id); + return { + id: row.event_uid, + title: row.title, + start: new Date(row.start_time), + end: new Date(row.end_time), + description: row.description, + location: row.location, + all_day: row.all_day === 1, + source_id: row.source_id, + source_name: source?.name || 'Unknown', + source_color: source?.color || '#6e44ff' + }; + }); + } + + getSyncStatus(sourceId) { + if (sourceId) { + return this.db.prepare('SELECT * FROM calendar_sync_status WHERE source_id = ?').get(sourceId); + } + return this.db.prepare(` + SELECT css.*, cs.name as source_name + FROM calendar_sync_status css + JOIN calendar_sources cs ON css.source_id = cs.id + `).all(); + } + + setSyncInterval(sourceId, intervalMinutes) { + this.db.prepare(` + INSERT OR REPLACE INTO calendar_sync_status (source_id, sync_interval_minutes) + VALUES (?, ?) + ON CONFLICT(source_id) DO UPDATE SET sync_interval_minutes = excluded.sync_interval_minutes + `).run(sourceId, intervalMinutes); + + this.restartSyncJob(sourceId); + } + + getSyncInterval(sourceId) { + const row = this.db.prepare('SELECT sync_interval_minutes FROM calendar_sync_status WHERE source_id = ?').get(sourceId); + return row?.sync_interval_minutes || 15; + } + + startSyncJob(sourceId) { + const interval = this.getSyncInterval(sourceId); + + if (this.syncIntervals.has(sourceId)) { + clearInterval(this.syncIntervals.get(sourceId)); + } + + if (interval <= 0) { + console.log(`Sync disabled for source ${sourceId}`); + return; + } + + const intervalMs = interval * 60 * 1000; + + const intervalId = setInterval(() => { + this.syncSource(sourceId).catch(err => { + console.error(`Scheduled sync failed for source ${sourceId}:`, err.message); + }); + }, intervalMs); + + this.syncIntervals.set(sourceId, intervalId); + console.log(`Started sync job for source ${sourceId} every ${interval} minutes`); + } + + restartSyncJob(sourceId) { + if (this.syncIntervals.has(sourceId)) { + clearInterval(this.syncIntervals.get(sourceId)); + this.syncIntervals.delete(sourceId); + } + this.startSyncJob(sourceId); + } + + startAllSyncJobs() { + const sources = this.db.prepare('SELECT id FROM calendar_sources WHERE enabled = 1').all(); + + for (const source of sources) { + this.startSyncJob(source.id); + } + + setTimeout(() => { + this.syncAllSources().catch(err => { + console.error('Initial sync failed:', err.message); + }); + }, 5000); + } + + stopAllSyncJobs() { + for (const [sourceId, intervalId] of this.syncIntervals) { + clearInterval(intervalId); + console.log(`Stopped sync job for source ${sourceId}`); + } + this.syncIntervals.clear(); + } + + onSourceCreated(sourceId) { + this.syncSource(sourceId).then(() => { + this.startSyncJob(sourceId); + }); + } + + onSourceUpdated(sourceId) { + this.syncSource(sourceId); + } + + onSourceDeleted(sourceId) { + if (this.syncIntervals.has(sourceId)) { + clearInterval(this.syncIntervals.get(sourceId)); + this.syncIntervals.delete(sourceId); + } + } + + onSourceToggled(sourceId, enabled) { + if (enabled) { + this.syncSource(sourceId).then(() => { + this.startSyncJob(sourceId); + }); + } else { + if (this.syncIntervals.has(sourceId)) { + clearInterval(this.syncIntervals.get(sourceId)); + this.syncIntervals.delete(sourceId); + } + } + } +} + +module.exports = CalendarSyncService; From 27f793033ff8d39428f846f095c8276565b2257f Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 11 Mar 2026 12:52:17 -0400 Subject: [PATCH 32/80] Updated app.jsx --- client/src/app.jsx | 91 ++- client/src/components/AdminPanel.jsx | 642 +++++++++++------- client/src/components/PluginWidgetWrapper.jsx | 25 + client/src/components/WidgetContainer.jsx | 20 +- client/src/components/WidgetGallery.jsx | 165 ----- 5 files changed, 497 insertions(+), 446 deletions(-) create mode 100644 client/src/components/PluginWidgetWrapper.jsx delete mode 100644 client/src/components/WidgetGallery.jsx diff --git a/client/src/app.jsx b/client/src/app.jsx index f3b341e..5a43945 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -1,6 +1,6 @@ // client/src/app.jsx import React, { useState, useEffect, useMemo } from 'react'; -import { Container, IconButton, Box, Dialog, DialogContent } from '@mui/material'; +import { IconButton, Box, Dialog, DialogContent } from '@mui/material'; import { Brightness4, Brightness7, Lock, LockOpen, Close } from '@mui/icons-material'; import SettingsIcon from '@mui/icons-material/Settings'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -11,7 +11,7 @@ import PhotoWidget from './components/PhotoWidget.jsx'; import AdminPanel from './components/AdminPanel.jsx'; import WeatherWidget from './components/WeatherWidget.jsx'; import ChoreWidget from './components/ChoreWidget.jsx'; -import WidgetGallery from './components/WidgetGallery.jsx'; +import PluginWidgetWrapper from './components/PluginWidgetWrapper.jsx'; import WidgetContainer from './components/WidgetContainer.jsx'; import TabBar from './components/TabBar.jsx'; import TabIconModal from './components/TabIconModal.jsx'; @@ -22,7 +22,7 @@ const App = () => { const [theme, setTheme] = useState('light'); const [widgetsLocked, setWidgetsLocked] = useState(() => { const saved = localStorage.getItem('widgetsLocked'); - return saved !== null ? JSON.parse(saved) : true; // Default to locked + return saved !== null ? JSON.parse(saved) : true; }); const [widgetSettings, setWidgetSettings] = useState(() => { const defaultSettings = { @@ -30,7 +30,6 @@ const App = () => { calendar: { enabled: false, transparent: false }, photos: { enabled: false, transparent: false }, weather: { enabled: false, transparent: false }, - widgetGallery: { enabled: true, transparent: false }, lightGradientStart: '#00ddeb', lightGradientEnd: '#ff6b6b', darkGradientStart: '#2e2767', @@ -49,16 +48,48 @@ const App = () => { WEATHER_API_KEY: '', ICS_CALENDAR_URL: '', }); - const [widgetGalleryKey, setWidgetGalleryKey] = useState(0); + const [installedPlugins, setInstalledPlugins] = useState([]); const [activeTab, setActiveTab] = useState(1); const [tabs, setTabs] = useState([]); const [widgetAssignments, setWidgetAssignments] = useState({}); const [showTabIconModal, setShowTabIconModal] = useState(false); - const refreshWidgetGallery = () => { - setWidgetGalleryKey(prev => prev + 1); + const fetchInstalledPlugins = async () => { + try { + const response = await axios.get(`${API_BASE_URL}/api/widgets`); + setInstalledPlugins(Array.isArray(response.data) ? response.data : []); + } catch { + setInstalledPlugins([]); + } }; + useEffect(() => { + const oldEnabled = localStorage.getItem('enabledWidgets'); + if (oldEnabled) { + try { + const parsed = JSON.parse(oldEnabled); + const existing = JSON.parse(localStorage.getItem('pluginSettings') || '{}'); + Object.entries(parsed).forEach(([filename, isEnabled]) => { + if (!existing[filename]) { + existing[filename] = { enabled: !!isEnabled, transparent: false, refreshInterval: 0 }; + } + }); + localStorage.setItem('pluginSettings', JSON.stringify(existing)); + } catch {} + localStorage.removeItem('enabledWidgets'); + } + const ws = localStorage.getItem('widgetSettings'); + if (ws) { + try { + const parsed = JSON.parse(ws); + if (parsed.widgetGallery) { + delete parsed.widgetGallery; + localStorage.setItem('widgetSettings', JSON.stringify(parsed)); + } + } catch {} + } + }, []); + useEffect(() => { const fetchApiKeys = async () => { try { @@ -71,6 +102,7 @@ const App = () => { fetchApiKeys(); fetchTabs(); fetchWidgetAssignments(); + fetchInstalledPlugins(); }, []); const fetchTabs = async () => { @@ -289,23 +321,38 @@ const App = () => { }); } + const pluginSettings = JSON.parse(localStorage.getItem('pluginSettings') || '{}'); + installedPlugins.forEach((plugin, index) => { + const pSettings = pluginSettings[plugin.filename] || {}; + if (!pSettings.enabled) return; + + const pluginWidgetName = `plugin:${plugin.filename}`; + if (!isWidgetAssignedToTab(pluginWidgetName, activeTab)) return; + + const dbLayout = getWidgetLayoutForTab(pluginWidgetName, activeTab); + result.push({ + id: `plugin-${plugin.filename}`, + defaultPosition: { x: 0, y: 10 + index * 4 }, + defaultSize: { width: 6, height: 4 }, + minWidth: 3, + minHeight: 3, + savedLayout: dbLayout, + content: , + }); + }); + return result; - }, [widgetSettings, activeTab, apiKeys, widgetAssignments]); + }, [widgetSettings, activeTab, apiKeys, widgetAssignments, installedPlugins, theme]); return ( <> {widgets.length > 0 && } - - {widgetSettings.widgetGallery?.enabled && activeTab === 1 && ( - 0 ? 1 : 0 }}> - - - )} @@ -325,7 +372,7 @@ const App = () => { > - + @@ -360,8 +407,8 @@ const App = () => { /> {/* Center: Control Buttons */} - { onClick={toggleWidgetsLock} aria-label={widgetsLocked ? "Unlock widgets" : "Lock widgets"} sx={{ - color: widgetsLocked + color: widgetsLocked ? (theme === 'light' ? 'action.active' : 'white') : 'var(--accent)', transition: 'color 0.2s ease', diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index 4259d93..c9742d7 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -65,9 +65,10 @@ import PinModal from './PinModal'; import ChoreSchedulesTab from './ChoreSchedulesTab'; import ChoreHistoryTab from './ChoreHistoryTab'; -const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { +const AdminPanel = ({ setWidgetSettings, onPluginsChanged }) => { const [activeTab, setActiveTab] = useState(0); const [choresSubTab, setChoresSubTab] = useState(0); + const [widgetsSubTab, setWidgetsSubTab] = useState(0); const [settings, setSettings] = useState({ WEATHER_API_KEY: '', PROXY_WHITELIST: '', @@ -78,7 +79,6 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { calendar: { enabled: false, transparent: false, refreshInterval: 0 }, photos: { enabled: false, transparent: false, refreshInterval: 0 }, weather: { enabled: false, transparent: false, refreshInterval: 0, layoutMode: 'medium' }, - widgetGallery: { enabled: true, transparent: false, refreshInterval: 0 }, primary: '#f5f5f5', secondary: '#38bdf8', accent: '#f472b6' @@ -104,6 +104,11 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { const [checkingPin, setCheckingPin] = useState(true); const [tabs, setTabs] = useState([]); const [widgetAssignments, setWidgetAssignments] = useState({}); + const [pluginSettings, setPluginSettings] = useState(() => { + const saved = localStorage.getItem('pluginSettings'); + return saved ? JSON.parse(saved) : {}; + }); + const [pluginAssignments, setPluginAssignments] = useState({}); // Refresh interval options in milliseconds const refreshIntervalOptions = [ @@ -134,7 +139,6 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { layoutMode: (parsed.weather?.layoutMode === 'auto' || !parsed.weather?.layoutMode) ? 'medium' : parsed.weather.layoutMode, ...parsed.weather }, - widgetGallery: { enabled: true, transparent: false, refreshInterval: 0, ...parsed.widgetGallery }, primary: parsed.primary || '#f5f5f5', secondary: parsed.secondary || '#38bdf8', accent: parsed.accent || '#f472b6' @@ -238,18 +242,28 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { const response = await axios.get(`${API_BASE_URL}/api/widget-assignments`); const assignments = Array.isArray(response.data) ? response.data : []; - const groupedAssignments = {}; + const coreAssignments = {}; + const pluginAssign = {}; assignments.forEach(assignment => { - if (!groupedAssignments[assignment.widget_name]) { - groupedAssignments[assignment.widget_name] = []; + if (assignment.widget_name.startsWith('plugin:')) { + if (!pluginAssign[assignment.widget_name]) { + pluginAssign[assignment.widget_name] = []; + } + pluginAssign[assignment.widget_name].push(assignment.tab_id); + } else { + if (!coreAssignments[assignment.widget_name]) { + coreAssignments[assignment.widget_name] = []; + } + coreAssignments[assignment.widget_name].push(assignment.tab_id); } - groupedAssignments[assignment.widget_name].push(assignment.tab_id); }); - setWidgetAssignments(groupedAssignments); + setWidgetAssignments(coreAssignments); + setPluginAssignments(pluginAssign); } catch (error) { console.error('Error fetching widget assignments:', error); setWidgetAssignments({}); + setPluginAssignments({}); } }; @@ -342,6 +356,45 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { } }; + const savePluginSettings = async () => { + setIsLoading(true); + try { + localStorage.setItem('pluginSettings', JSON.stringify(pluginSettings)); + + const currentResponse = await axios.get(`${API_BASE_URL}/api/widget-assignments`); + const currentAssignments = Array.isArray(currentResponse.data) ? currentResponse.data : []; + + for (const [pluginWidgetName, desiredTabIds] of Object.entries(pluginAssignments)) { + const existing = currentAssignments.filter(a => a.widget_name === pluginWidgetName); + const existingTabIds = existing.map(a => a.tab_id); + + const toRemove = existing.filter(a => !desiredTabIds.includes(a.tab_id)); + const toAdd = desiredTabIds.filter(id => !existingTabIds.includes(id)); + + for (const assignment of toRemove) { + await axios.delete(`${API_BASE_URL}/api/widget-assignments/${assignment.id}`); + } + + for (const tabId of toAdd) { + await axios.post(`${API_BASE_URL}/api/widget-assignments`, { + widget_name: pluginWidgetName, + tab_id: tabId, + }); + } + } + + if (onPluginsChanged) onPluginsChanged(); + setSaveMessage({ show: true, type: 'success', text: 'Plugin settings saved successfully! Refresh page to see changes.' }); + setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + } catch (error) { + console.error('Error saving plugin settings:', error); + setSaveMessage({ show: true, type: 'error', text: 'Failed to save plugin settings. Please try again.' }); + setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000); + } finally { + setIsLoading(false); + } + }; + const handleWidgetAssignmentChange = (widgetName, selectedTabIds) => { setWidgetAssignments(prev => ({ ...prev, @@ -537,7 +590,7 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { headers: { 'Content-Type': 'multipart/form-data' } }); fetchUploadedWidgets(); - if (onWidgetUploaded) onWidgetUploaded(); + if (onPluginsChanged) onPluginsChanged(); } catch (error) { console.error('Error uploading widget:', error); alert('Failed to upload widget. Please try again.'); @@ -551,8 +604,21 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { try { setIsLoading(true); await axios.delete(`${API_BASE_URL}/api/widgets/${filename}`); + const pluginWidgetName = `plugin:${filename}`; + await axios.delete(`${API_BASE_URL}/api/widget-assignments/widget/${encodeURIComponent(pluginWidgetName)}`).catch(() => {}); + setPluginSettings(prev => { + const updated = { ...prev }; + delete updated[filename]; + localStorage.setItem('pluginSettings', JSON.stringify(updated)); + return updated; + }); + setPluginAssignments(prev => { + const updated = { ...prev }; + delete updated[pluginWidgetName]; + return updated; + }); fetchUploadedWidgets(); - if (onWidgetUploaded) onWidgetUploaded(); + if (onPluginsChanged) onPluginsChanged(); } catch (error) { console.error('Error deleting widget:', error); } finally { @@ -570,7 +636,7 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { name: widget.name }); fetchUploadedWidgets(); - if (onWidgetUploaded) onWidgetUploaded(); + if (onPluginsChanged) onPluginsChanged(); alert(`Widget "${widget.name}" installed successfully!`); } catch (error) { console.error('Error installing GitHub widget:', error); @@ -803,7 +869,6 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { 'Users', 'Chores', 'Prizes', - 'Plugins', 'Security' ]; @@ -902,7 +967,12 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { {activeTab === 1 && ( - Widget Settings + + setWidgetsSubTab(v)} size="small"> + + + + {saveMessage.show && ( @@ -910,108 +980,108 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { )} - - Enable widgets to show them on the dashboard. Click to select a widget, then drag to move or resize from corners. - + {widgetsSubTab === 0 && ( + <> + + Enable widgets to show them on the dashboard. Click to select a widget, then drag to move or resize from corners. + - {/* Core Widgets */} - {Object.entries(widgetSettings).filter(([key]) => - ['chores', 'calendar', 'photos'].includes(key) - ).map(([widget, config]) => ( - - - {widget} Widget - - - - - handleWidgetToggle(widget, 'enabled')} + {Object.entries(widgetSettings).filter(([key]) => + ['chores', 'calendar', 'photos'].includes(key) + ).map(([widget, config]) => ( + + + {widget} Widget + + + + + handleWidgetToggle(widget, 'enabled')} + /> + } + label="Enabled" /> - } - label="Enabled" - /> - handleWidgetToggle(widget, 'transparent')} + handleWidgetToggle(widget, 'transparent')} + /> + } + label="Transparent Background" + sx={{ ml: 2 }} /> - } - label="Transparent Background" - sx={{ ml: 2 }} - /> - - - - - - - - Auto-Refresh Interval - - - - - - - - - option.label} - value={tabs.filter(tab => widgetAssignments[widget]?.includes(tab.id))} - onChange={(e, newValue) => { - handleWidgetAssignmentChange(widget, newValue.map(tab => tab.id)); - }} - renderInput={(params) => ( - + + + + + + + Auto-Refresh Interval + + + + + + + + + option.label} + value={tabs.filter(tab => widgetAssignments[widget]?.includes(tab.id))} + onChange={(e, newValue) => { + handleWidgetAssignmentChange(widget, newValue.map(tab => tab.id)); + }} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } /> - )} - renderTags={(value, getTagProps) => - value.map((option, index) => ( - - )) - } - /> - + - {config.refreshInterval > 0 && ( - }> - This widget will automatically refresh every {getRefreshIntervalLabel(config.refreshInterval).toLowerCase()} - - )} - - ))} + {config.refreshInterval > 0 && ( + }> + This widget will automatically refresh every {getRefreshIntervalLabel(config.refreshInterval).toLowerCase()} + + )} + + ))} - {/* Weather Widget with Layout Mode */} - - - 🌤️ Weather Widget - + + + Weather Widget + @@ -1167,72 +1237,217 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { )} - {/* Widget Gallery Settings */} - - - 🎨 Widget Gallery - - - The Widget Gallery displays custom uploaded widgets and is only available on the Home tab. - - - - - handleWidgetToggle('widgetGallery', 'enabled')} - /> - } - label="Show Widget Gallery" - /> - handleWidgetToggle('widgetGallery', 'transparent')} - /> - } - label="Transparent Background" - sx={{ ml: 2 }} - /> - - - - - - - - Auto-Refresh Interval - - - + + + Uploaded Widgets + + {uploadedWidgets.map((widget) => ( + + + + deleteWidget(widget.filename)} color="error"> + + + + ))} - - + + + + + + GitHub Widget Repository + + + + + {githubWidgets.map((widget) => ( + + + + + + + ))} + + - - - {widgetSettings.widgetGallery?.refreshInterval > 0 && ( - }> - Widget Gallery will automatically refresh every {getRefreshIntervalLabel(widgetSettings.widgetGallery.refreshInterval).toLowerCase()} - - )} - - - + + {uploadedWidgets.length > 0 && ( + <> + + Plugin Settings + + Configure each installed plugin below. Enable them, set transparency, refresh intervals, and assign to tabs just like core widgets. + + + {uploadedWidgets.map((plugin) => { + const pSettings = pluginSettings[plugin.filename] || {}; + const pluginWidgetName = `plugin:${plugin.filename}`; + return ( + + + + + {plugin.name} + + + {plugin.filename} + + + deleteWidget(plugin.filename)} color="error" size="small"> + + + + + + + { + setPluginSettings(prev => ({ + ...prev, + [plugin.filename]: { ...prev[plugin.filename], enabled: !(prev[plugin.filename]?.enabled) } + })); + }} + /> + } + label="Enabled" + /> + { + setPluginSettings(prev => ({ + ...prev, + [plugin.filename]: { ...prev[plugin.filename], transparent: !(prev[plugin.filename]?.transparent) } + })); + }} + /> + } + label="Transparent Background" + sx={{ ml: 2 }} + /> + + + + + + + + Auto-Refresh Interval + + + + + + + + + option.label} + value={tabs.filter(tab => pluginAssignments[pluginWidgetName]?.includes(tab.id))} + onChange={(e, newValue) => { + setPluginAssignments(prev => ({ + ...prev, + [pluginWidgetName]: newValue.map(tab => tab.id) + })); + }} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + + ); + })} + + + + )} + + )} )} @@ -1565,89 +1780,8 @@ const AdminPanel = ({ setWidgetSettings, onWidgetUploaded }) => { )} - {/* Plugins Tab */} - {activeTab === 6 && ( - - - Plugin Management - - - - Upload Custom Widget - - - Uploaded Widgets - - {uploadedWidgets.map((widget) => ( - - - - deleteWidget(widget.filename)} color="error"> - - - - - ))} - - - - - - GitHub Widget Repository - - - - - {githubWidgets.map((widget) => ( - - - - - - - ))} - - - - - - )} - {/* Security Tab */} - {activeTab === 7 && ( + {activeTab === 6 && ( Security Settings diff --git a/client/src/components/PluginWidgetWrapper.jsx b/client/src/components/PluginWidgetWrapper.jsx new file mode 100644 index 0000000..cb216a6 --- /dev/null +++ b/client/src/components/PluginWidgetWrapper.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { API_BASE_URL } from '../utils/apiConfig.js'; + +const PluginWidgetWrapper = ({ filename, name, theme, transparentBackground = false }) => { + return ( + +