Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Vijay Janapa Reddi
2026-02-28 14:21:48 -05:00
23 changed files with 1307 additions and 659 deletions

View File

@@ -125,13 +125,6 @@
"profile": "https://github.com/jaysonzlin",
"contributions": []
},
{
"login": "andreamurillomtz",
"name": "Andrea",
"avatar_url": "https://avatars.githubusercontent.com/andreamurillomtz",
"profile": "https://github.com/andreamurillomtz",
"contributions": []
},
{
"login": "sophiacho1",
"name": "Sophia Cho",
@@ -139,6 +132,13 @@
"profile": "https://github.com/sophiacho1",
"contributions": []
},
{
"login": "andreamurillomtz",
"name": "Andrea",
"avatar_url": "https://avatars.githubusercontent.com/andreamurillomtz",
"profile": "https://github.com/andreamurillomtz",
"contributions": []
},
{
"login": "alxrod",
"name": "Alex Rodriguez",
@@ -181,6 +181,13 @@
"profile": "https://github.com/colbybanbury",
"contributions": []
},
{
"login": "GabrielAmazonas",
"name": "Gabriel Amazonas",
"avatar_url": "https://avatars.githubusercontent.com/GabrielAmazonas",
"profile": "https://github.com/GabrielAmazonas",
"contributions": []
},
{
"login": "mmaz",
"name": "Mark Mazumder",
@@ -189,10 +196,10 @@
"contributions": []
},
{
"login": "GabrielAmazonas",
"name": "Gabriel Amazonas",
"avatar_url": "https://avatars.githubusercontent.com/GabrielAmazonas",
"profile": "https://github.com/GabrielAmazonas",
"login": "ma3mool",
"name": "Abdulrahman Mahmoud",
"avatar_url": "https://avatars.githubusercontent.com/ma3mool",
"profile": "https://github.com/ma3mool",
"contributions": []
},
{
@@ -209,13 +216,6 @@
"profile": "https://github.com/srivatsankrishnan",
"contributions": []
},
{
"login": "ma3mool",
"name": "Abdulrahman Mahmoud",
"avatar_url": "https://avatars.githubusercontent.com/ma3mool",
"profile": "https://github.com/ma3mool",
"contributions": []
},
{
"login": "aptl26",
"name": "Aghyad Deeb",
@@ -223,13 +223,6 @@
"profile": "https://github.com/aptl26",
"contributions": []
},
{
"login": "arnaumarin",
"name": "marin-llobet",
"avatar_url": "https://avatars.githubusercontent.com/arnaumarin",
"profile": "https://github.com/arnaumarin",
"contributions": []
},
{
"login": "James-QiuHaoran",
"name": "Haoran Qiu",
@@ -238,17 +231,10 @@
"contributions": []
},
{
"login": "AditiR-42",
"name": "Aditi Raju",
"avatar_url": "https://avatars.githubusercontent.com/AditiR-42",
"profile": "https://github.com/AditiR-42",
"contributions": []
},
{
"login": "MichaelSchnebly",
"name": "Michael Schnebly",
"avatar_url": "https://avatars.githubusercontent.com/MichaelSchnebly",
"profile": "https://github.com/MichaelSchnebly",
"login": "arnaumarin",
"name": "marin-llobet",
"avatar_url": "https://avatars.githubusercontent.com/arnaumarin",
"profile": "https://github.com/arnaumarin",
"contributions": []
},
{
@@ -259,10 +245,10 @@
"contributions": []
},
{
"login": "ELSuitorHarvard",
"name": "ELSuitorHarvard",
"avatar_url": "https://avatars.githubusercontent.com/ELSuitorHarvard",
"profile": "https://github.com/ELSuitorHarvard",
"login": "MichaelSchnebly",
"name": "Michael Schnebly",
"avatar_url": "https://avatars.githubusercontent.com/MichaelSchnebly",
"profile": "https://github.com/MichaelSchnebly",
"contributions": []
},
{
@@ -273,17 +259,10 @@
"contributions": []
},
{
"login": "kaiM0ves",
"name": "kaiM0ves",
"avatar_url": "https://avatars.githubusercontent.com/kaiM0ves",
"profile": "https://github.com/kaiM0ves",
"contributions": []
},
{
"login": "jared-ni",
"name": "Jared Ni",
"avatar_url": "https://avatars.githubusercontent.com/jared-ni",
"profile": "https://github.com/jared-ni",
"login": "ELSuitorHarvard",
"name": "ELSuitorHarvard",
"avatar_url": "https://avatars.githubusercontent.com/ELSuitorHarvard",
"profile": "https://github.com/ELSuitorHarvard",
"contributions": []
},
{
@@ -293,6 +272,27 @@
"profile": "https://github.com/oishib",
"contributions": []
},
{
"login": "jared-ni",
"name": "Jared Ni",
"avatar_url": "https://avatars.githubusercontent.com/jared-ni",
"profile": "https://github.com/jared-ni",
"contributions": []
},
{
"login": "kaiM0ves",
"name": "kaiM0ves",
"avatar_url": "https://avatars.githubusercontent.com/kaiM0ves",
"profile": "https://github.com/kaiM0ves",
"contributions": []
},
{
"login": "AditiR-42",
"name": "Aditi Raju",
"avatar_url": "https://avatars.githubusercontent.com/AditiR-42",
"profile": "https://github.com/AditiR-42",
"contributions": []
},
{
"login": "eimlav",
"name": "Eimhin Laverty",
@@ -300,20 +300,6 @@
"profile": "https://github.com/eimlav",
"contributions": []
},
{
"login": "leo47007",
"name": "Yu-Shun Hsiao",
"avatar_url": "https://avatars.githubusercontent.com/leo47007",
"profile": "https://github.com/leo47007",
"contributions": []
},
{
"login": "jaywonchung",
"name": "Jae-Won Chung",
"avatar_url": "https://avatars.githubusercontent.com/jaywonchung",
"profile": "https://github.com/jaywonchung",
"contributions": []
},
{
"login": "BaeHenryS",
"name": "Henry Bae",
@@ -321,6 +307,20 @@
"profile": "https://github.com/BaeHenryS",
"contributions": []
},
{
"login": "jaywonchung",
"name": "Jae-Won Chung",
"avatar_url": "https://avatars.githubusercontent.com/jaywonchung",
"profile": "https://github.com/jaywonchung",
"contributions": []
},
{
"login": "leo47007",
"name": "Yu-Shun Hsiao",
"avatar_url": "https://avatars.githubusercontent.com/leo47007",
"profile": "https://github.com/leo47007",
"contributions": []
},
{
"login": "AndreaMattiaGaravagno",
"name": "AndreaMattiaGaravagno",
@@ -329,10 +329,31 @@
"contributions": []
},
{
"login": "pongtr",
"name": "Pong Trairatvorakul",
"avatar_url": "https://avatars.githubusercontent.com/pongtr",
"profile": "https://github.com/pongtr",
"login": "arbass22",
"name": "Andrew Bass",
"avatar_url": "https://avatars.githubusercontent.com/arbass22",
"profile": "https://github.com/arbass22",
"contributions": []
},
{
"login": "aryatschand",
"name": "Arya Tschand",
"avatar_url": "https://avatars.githubusercontent.com/aryatschand",
"profile": "https://github.com/aryatschand",
"contributions": []
},
{
"login": "ShvetankPrakash",
"name": "Shvetank Prakash",
"avatar_url": "https://avatars.githubusercontent.com/ShvetankPrakash",
"profile": "https://github.com/ShvetankPrakash",
"contributions": []
},
{
"login": "jianqingdu",
"name": "jianqingdu",
"avatar_url": "https://avatars.githubusercontent.com/jianqingdu",
"profile": "https://github.com/jianqingdu",
"contributions": []
},
{
@@ -349,41 +370,6 @@
"profile": "https://github.com/harvard-edge/cs249r_book/graphs/contributors",
"contributions": []
},
{
"login": "marcozennaro",
"name": "Marco Zennaro",
"avatar_url": "https://avatars.githubusercontent.com/marcozennaro",
"profile": "https://github.com/marcozennaro",
"contributions": []
},
{
"login": "jianqingdu",
"name": "jianqingdu",
"avatar_url": "https://avatars.githubusercontent.com/jianqingdu",
"profile": "https://github.com/jianqingdu",
"contributions": []
},
{
"login": "ShvetankPrakash",
"name": "Shvetank Prakash",
"avatar_url": "https://avatars.githubusercontent.com/ShvetankPrakash",
"profile": "https://github.com/ShvetankPrakash",
"contributions": []
},
{
"login": "aryatschand",
"name": "Arya Tschand",
"avatar_url": "https://avatars.githubusercontent.com/aryatschand",
"profile": "https://github.com/aryatschand",
"contributions": []
},
{
"login": "arbass22",
"name": "Andrew Bass",
"avatar_url": "https://avatars.githubusercontent.com/arbass22",
"profile": "https://github.com/arbass22",
"contributions": []
},
{
"login": "jzhou1318",
"name": "Jennifer Zhou",
@@ -399,24 +385,24 @@
"contributions": []
},
{
"login": "Fatima Shah",
"name": "Fatima Shah",
"avatar_url": "https://www.gravatar.com/avatar/468ef35acc69f3266efd700992daa369?d=identicon&s=100",
"profile": "https://github.com/harvard-edge/cs249r_book/graphs/contributors",
"login": "marcozennaro",
"name": "Marco Zennaro",
"avatar_url": "https://avatars.githubusercontent.com/marcozennaro",
"profile": "https://github.com/marcozennaro",
"contributions": []
},
{
"login": "taunoe",
"name": "Tauno Erik",
"avatar_url": "https://avatars.githubusercontent.com/taunoe",
"profile": "https://github.com/taunoe",
"login": "pongtr",
"name": "Pong Trairatvorakul",
"avatar_url": "https://avatars.githubusercontent.com/pongtr",
"profile": "https://github.com/pongtr",
"contributions": []
},
{
"login": "serco425",
"name": "Sercan Ayg\u00fcn",
"avatar_url": "https://avatars.githubusercontent.com/serco425",
"profile": "https://github.com/serco425",
"login": "alex-oesterling",
"name": "Alex Oesterling",
"avatar_url": "https://avatars.githubusercontent.com/alex-oesterling",
"profile": "https://github.com/alex-oesterling",
"contributions": []
},
{
@@ -426,6 +412,13 @@
"profile": "https://github.com/harvard-edge/cs249r_book/graphs/contributors",
"contributions": []
},
{
"login": "Fatima Shah",
"name": "Fatima Shah",
"avatar_url": "https://www.gravatar.com/avatar/468ef35acc69f3266efd700992daa369?d=identicon&s=100",
"profile": "https://github.com/harvard-edge/cs249r_book/graphs/contributors",
"contributions": []
},
{
"login": "vitasam",
"name": "The Random DIY",
@@ -433,20 +426,6 @@
"profile": "https://github.com/vitasam",
"contributions": []
},
{
"login": "TheHiddenLayer",
"name": "TheHiddenLayer",
"avatar_url": "https://avatars.githubusercontent.com/TheHiddenLayer",
"profile": "https://github.com/TheHiddenLayer",
"contributions": []
},
{
"login": "FinAminToastCrunch",
"name": "Fin Amin",
"avatar_url": "https://avatars.githubusercontent.com/FinAminToastCrunch",
"profile": "https://github.com/FinAminToastCrunch",
"contributions": []
},
{
"login": "BunningsWarehouseOfficial",
"name": "Kristian Rado\u0161",
@@ -454,34 +433,6 @@
"profile": "https://github.com/BunningsWarehouseOfficial",
"contributions": []
},
{
"login": "BrunoScaglione",
"name": "Bruno Scaglione",
"avatar_url": "https://avatars.githubusercontent.com/BrunoScaglione",
"profile": "https://github.com/BrunoScaglione",
"contributions": []
},
{
"login": "Allen-Kuang",
"name": "Allen-Kuang",
"avatar_url": "https://avatars.githubusercontent.com/Allen-Kuang",
"profile": "https://github.com/Allen-Kuang",
"contributions": []
},
{
"login": "alex-oesterling",
"name": "Alex Oesterling",
"avatar_url": "https://avatars.githubusercontent.com/alex-oesterling",
"profile": "https://github.com/alex-oesterling",
"contributions": []
},
{
"login": "gnodipac886",
"name": "gnodipac886",
"avatar_url": "https://avatars.githubusercontent.com/gnodipac886",
"profile": "https://github.com/gnodipac886",
"contributions": []
},
{
"login": "Gjain234",
"name": "Gauri Jain",
@@ -490,17 +441,10 @@
"contributions": []
},
{
"login": "aethernavshulkraven-allain",
"name": "\u0905\u0930\u0928\u0935 \u0936\u0941\u0915\u094d\u0932\u093e | Arnav Shukla",
"avatar_url": "https://avatars.githubusercontent.com/aethernavshulkraven-allain",
"profile": "https://github.com/aethernavshulkraven-allain",
"contributions": []
},
{
"login": "KarthikDani",
"name": "Karthik Dani",
"avatar_url": "https://avatars.githubusercontent.com/KarthikDani",
"profile": "https://github.com/KarthikDani",
"login": "taunoe",
"name": "Tauno Erik",
"avatar_url": "https://avatars.githubusercontent.com/taunoe",
"profile": "https://github.com/taunoe",
"contributions": []
},
{
@@ -510,6 +454,69 @@
"profile": "https://github.com/RinZ27",
"contributions": []
},
{
"login": "TheHiddenLayer",
"name": "TheHiddenLayer",
"avatar_url": "https://avatars.githubusercontent.com/TheHiddenLayer",
"profile": "https://github.com/TheHiddenLayer",
"contributions": []
},
{
"login": "Allen-Kuang",
"name": "Allen-Kuang",
"avatar_url": "https://avatars.githubusercontent.com/Allen-Kuang",
"profile": "https://github.com/Allen-Kuang",
"contributions": []
},
{
"login": "FinAminToastCrunch",
"name": "Fin Amin",
"avatar_url": "https://avatars.githubusercontent.com/FinAminToastCrunch",
"profile": "https://github.com/FinAminToastCrunch",
"contributions": []
},
{
"login": "BrunoScaglione",
"name": "Bruno Scaglione",
"avatar_url": "https://avatars.githubusercontent.com/BrunoScaglione",
"profile": "https://github.com/BrunoScaglione",
"contributions": []
},
{
"login": "serco425",
"name": "Sercan Ayg\u00fcn",
"avatar_url": "https://avatars.githubusercontent.com/serco425",
"profile": "https://github.com/serco425",
"contributions": []
},
{
"login": "gnodipac886",
"name": "gnodipac886",
"avatar_url": "https://avatars.githubusercontent.com/gnodipac886",
"profile": "https://github.com/gnodipac886",
"contributions": []
},
{
"login": "YangZhou1997",
"name": "Yang Zhou",
"avatar_url": "https://avatars.githubusercontent.com/YangZhou1997",
"profile": "https://github.com/YangZhou1997",
"contributions": []
},
{
"login": "aethernavshulkraven-allain",
"name": "\u0905\u0930\u0928\u0935 \u0936\u0941\u0915\u094d\u0932\u093e | Arnav Shukla",
"avatar_url": "https://avatars.githubusercontent.com/aethernavshulkraven-allain",
"profile": "https://github.com/aethernavshulkraven-allain",
"contributions": []
},
{
"login": "arighosh05",
"name": "Aritra Ghosh",
"avatar_url": "https://avatars.githubusercontent.com/arighosh05",
"profile": "https://github.com/arighosh05",
"contributions": []
},
{
"login": "XaicuL",
"name": "JEON HYUNJUN(Luciano)",
@@ -517,6 +524,20 @@
"profile": "https://github.com/XaicuL",
"contributions": []
},
{
"login": "KarthikDani",
"name": "Karthik Dani",
"avatar_url": "https://avatars.githubusercontent.com/KarthikDani",
"profile": "https://github.com/KarthikDani",
"contributions": []
},
{
"login": "Pratham-ja",
"name": "Pratham Chaudhary",
"avatar_url": "https://avatars.githubusercontent.com/Pratham-ja",
"profile": "https://github.com/Pratham-ja",
"contributions": []
},
{
"login": "Jahnic-kb",
"name": "Jahnic Beck",
@@ -524,13 +545,6 @@
"profile": "https://github.com/Jahnic-kb",
"contributions": []
},
{
"login": "AbenezerKb",
"name": "Abenezer Angamo",
"avatar_url": "https://avatars.githubusercontent.com/AbenezerKb",
"profile": "https://github.com/AbenezerKb",
"contributions": []
},
{
"login": "BravoBaldo",
"name": "Baldassarre Cesarano",
@@ -538,6 +552,20 @@
"profile": "https://github.com/BravoBaldo",
"contributions": []
},
{
"login": "AbenezerKb",
"name": "Abenezer Angamo",
"avatar_url": "https://avatars.githubusercontent.com/AbenezerKb",
"profile": "https://github.com/AbenezerKb",
"contributions": []
},
{
"login": "abigailswallow",
"name": "abigailswallow",
"avatar_url": "https://avatars.githubusercontent.com/abigailswallow",
"profile": "https://github.com/abigailswallow",
"contributions": []
},
{
"login": "adil-mubashir-ch",
"name": "Adil Mubashir Chaudhry",
@@ -545,13 +573,6 @@
"profile": "https://github.com/adil-mubashir-ch",
"contributions": []
},
{
"login": "YangZhou1997",
"name": "Yang Zhou",
"avatar_url": "https://avatars.githubusercontent.com/YangZhou1997",
"profile": "https://github.com/YangZhou1997",
"contributions": []
},
{
"login": "cursoragent",
"name": "Cursor Agent",
@@ -566,13 +587,6 @@
"profile": "https://github.com/bilgeacun",
"contributions": []
},
{
"login": "arighosh05",
"name": "Aritra Ghosh",
"avatar_url": "https://avatars.githubusercontent.com/arighosh05",
"profile": "https://github.com/arighosh05",
"contributions": []
},
{
"login": "atcheng2",
"name": "Andy Cheng",
@@ -581,17 +595,10 @@
"contributions": []
},
{
"login": "abigailswallow",
"name": "abigailswallow",
"avatar_url": "https://avatars.githubusercontent.com/abigailswallow",
"profile": "https://github.com/abigailswallow",
"contributions": []
},
{
"login": "emmanuel2406",
"name": "Emmanuel Rassou",
"avatar_url": "https://avatars.githubusercontent.com/emmanuel2406",
"profile": "https://github.com/emmanuel2406",
"login": "salmanmkc",
"name": "Salman Chishti",
"avatar_url": "https://avatars.githubusercontent.com/salmanmkc",
"profile": "https://github.com/salmanmkc",
"contributions": []
},
{
@@ -601,6 +608,20 @@
"profile": "https://github.com/happyappledog",
"contributions": []
},
{
"login": "emmanuel2406",
"name": "Emmanuel Rassou",
"avatar_url": "https://avatars.githubusercontent.com/emmanuel2406",
"profile": "https://github.com/emmanuel2406",
"contributions": []
},
{
"login": "jasonlyik",
"name": "Jason Yik",
"avatar_url": "https://avatars.githubusercontent.com/jasonlyik",
"profile": "https://github.com/jasonlyik",
"contributions": []
},
{
"login": "jessicaquaye",
"name": "Jessica Quaye",
@@ -608,6 +629,13 @@
"profile": "https://github.com/jessicaquaye",
"contributions": []
},
{
"login": "skmur",
"name": "Sonia Murthy",
"avatar_url": "https://avatars.githubusercontent.com/skmur",
"profile": "https://github.com/skmur",
"contributions": []
},
{
"login": "sjohri20",
"name": "Shreya Johri",
@@ -622,20 +650,6 @@
"profile": "https://github.com/pipme",
"contributions": []
},
{
"login": "jasonlyik",
"name": "Jason Yik",
"avatar_url": "https://avatars.githubusercontent.com/jasonlyik",
"profile": "https://github.com/jasonlyik",
"contributions": []
},
{
"login": "snuggs",
"name": "Snuggs",
"avatar_url": "https://avatars.githubusercontent.com/snuggs",
"profile": "https://github.com/snuggs",
"contributions": []
},
{
"login": "swilcock0",
"name": "Sam Wilcock",
@@ -644,10 +658,10 @@
"contributions": []
},
{
"login": "skmur",
"name": "Sonia Murthy",
"avatar_url": "https://avatars.githubusercontent.com/skmur",
"profile": "https://github.com/skmur",
"login": "snuggs",
"name": "Snuggs",
"avatar_url": "https://avatars.githubusercontent.com/snuggs",
"profile": "https://github.com/snuggs",
"contributions": []
},
{
@@ -713,6 +727,13 @@
"profile": "https://github.com/harvard-edge/cs249r_book/graphs/contributors",
"contributions": []
},
{
"login": "Pratham-ja",
"name": "Pratham-ja",
"avatar_url": "https://www.gravatar.com/avatar/438310e71ca943b41e92065c2dbbddb6?d=identicon&s=100",
"profile": "https://github.com/harvard-edge/cs249r_book/graphs/contributors",
"contributions": []
},
{
"login": "Edward Jin",
"name": "Edward Jin",

View File

@@ -520,6 +520,7 @@ Thanks goes to these wonderful people who have contributed to making this resour
<td align="center" valign="top" width="11.11%"><a href="https://github.com/harishb00a"><img src="https://avatars.githubusercontent.com/harishb00a?v=4?s=50" width="50px;" alt="harishb00a"/><br /><sub><b>harishb00a</b></sub></a><br />✍️</td>
<td align="center" valign="top" width="11.11%"><a href="https://github.com/sotoblanco"><img src="https://avatars.githubusercontent.com/u/46135649?v=4?v=4?s=50" width="50px;" alt="Pastor Soto"/><br /><sub><b>Pastor Soto</b></sub></a><br />✍️</td>
<td align="center" valign="top" width="11.11%"><a href="https://github.com/salmanmkc"><img src="https://avatars.githubusercontent.com/u/32169182?v=4?v=4?s=50" width="50px;" alt="Salman Chishti"/><br /><sub><b>Salman Chishti</b></sub></a><br />🧑‍💻</td>
<td align="center" valign="top" width="11.11%"><a href="https://github.com/adityamulik"><img src="https://avatars.githubusercontent.com/u/10626835?v=4?v=4?s=50" width="50px;" alt="Aditya Mulik"/><br /><sub><b>Aditya Mulik</b></sub></a><br />✍️</td>
</tr>
</tbody>
</table>

View File

@@ -234,6 +234,15 @@
"contributions": [
"code"
]
},
{
"login": "adityamulik",
"name": "Aditya Mulik",
"avatar_url": "https://avatars.githubusercontent.com/u/10626835?v=4",
"profile": "https://github.com/adityamulik",
"contributions": [
"doc"
]
}
]
}

View File

@@ -295,6 +295,7 @@ Thanks to these wonderful people who helped improve TinyTorch!
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/salmanmkc"><img src="https://avatars.githubusercontent.com/u/32169182?v=4?v=4?s=80" width="80px;" alt="Salman Chishti"/><br /><sub><b>Salman Chishti</b></sub></a><br />🧑‍💻</td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/adityamulik"><img src="https://avatars.githubusercontent.com/u/10626835?v=4?v=4?s=80" width="80px;" alt="Aditya Mulik"/><br /><sub><b>Aditya Mulik</b></sub></a><br />✍️</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,8 @@
# Secrets
tests/e2e/credentials.json
# Playwright
test-results/
playwright-report/
blob-report/
playwright/.cache/

View File

@@ -1,8 +1,8 @@
import { injectStyles } from './modules/styles.js';
import { renderLayout, updateNavState } from './modules/ui.js?v=3';
import { getSession } from './modules/state.js?v=2';
import { openModal, closeModal, handleToggle, handleAuth, handleLogout, setMode, verifySession, signInWithSocial, supabase } from './modules/auth.js?v=3';
import { openProfileModal, closeProfileModal, handleProfileUpdate, geocodeAndSetCoordinates, checkAndAutoUpdateLocation } from './modules/profile.js';
import { renderLayout, updateNavState } from './modules/ui.js';
import { getSession } from './modules/state.js';
import { openModal, closeModal, handleToggle, handleAuth, handleLogout, setMode, verifySession, signInWithSocial, supabase } from './modules/auth.js';
import { openProfileModal, closeProfileModal, handleProfileUpdate, geocodeAndSetCoordinates, checkAndAutoUpdateLocation, setupProfileDeleteEvents } from './modules/profile.js';
import { setupCameraEvents } from './modules/camera.js';
import { getBasePath } from './modules/config.js';
@@ -32,24 +32,8 @@ import { getBasePath } from './modules/config.js';
});
}
// 2.6 Check for Supabase Session & Verify
const checkProfile = async (session) => {
if (!session || window.location.pathname.includes('profile_setup')) return;
const { data: profile } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', session.user.id)
.single();
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
if (!hasName || !hasInst || !hasLoc) {
window.location.href = getBasePath() + '/profile_setup.html';
}
};
// Initialize profile events
setupProfileDeleteEvents();
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
@@ -57,13 +41,18 @@ import { getBasePath } from './modules/config.js';
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
if (session.user) localStorage.setItem("tinytorch_user", JSON.stringify(session.user));
// Clean URL hash if present (Supabase puts tokens there)
if (window.location.hash && window.location.hash.includes('access_token')) {
window.history.replaceState({}, document.title, window.location.pathname + window.location.search);
// Clean URL of tokens/code (Supabase puts tokens in hash or code in query)
if ((window.location.hash && window.location.hash.includes('access_token')) ||
(window.location.search && window.location.search.includes('code='))) {
// Remove sensitive parameters while preserving non-sensitive ones
const url = new URL(window.location);
url.hash = '';
url.searchParams.delete('code');
url.searchParams.delete('type');
window.history.replaceState({}, document.title, url.toString());
}
updateNavState();
checkProfile(session);
}
// 3. Verify Session (Async)
verifySession();
@@ -79,7 +68,6 @@ import { getBasePath } from './modules/config.js';
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
if (session.user) localStorage.setItem("tinytorch_user", JSON.stringify(session.user));
updateNavState();
checkProfile(session);
} else if (event === 'SIGNED_OUT') {
localStorage.removeItem("tinytorch_token");
updateNavState();
@@ -168,10 +156,13 @@ import { getBasePath } from './modules/config.js';
const action = params.get('action');
if (action === 'login') {
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
updateNavState();
const { isLoggedIn } = getSession();
if (!isLoggedIn) {
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
updateNavState();
}
openModal('login');
} else if (action === 'profile') {
const { isLoggedIn } = getSession();

View File

@@ -289,15 +289,18 @@
<div class="legend-row">
<span class="legend-dot"></span>
<span id="member-count">0 Members</span>
<span id="member-count">0 Members on Map</span>
</div>
<div class="legend-row">
<span class="legend-dot" style="background-color: #2ecc71; border-color: #27ae60;"></span>
<span id="no-location-count">0 without Location (Lost at Sea)</span>
</div>
<div class="legend-row">
<span class="legend-dot-institution"></span>
<span id="institution-count">0 Institutions</span>
</div>
<div class="legend-row">
<span class="legend-dot" style="background-color: #2ecc71; border-color: #27ae60;"></span>
<span>Members with Unknown Location</span>
<div class="legend-row" style="margin-top: 5px; border-top: 1px dashed #eee; padding-top: 8px; font-style: italic; color: #888;">
Note: Members without a location set in their profile are placed at the Sea Station.
</div>
</div>
@@ -307,7 +310,6 @@
<svg id="globe-svg"></svg>
<script type="module">
import { initCloud, updateCloudUsers, animateCloud } from './modules/cloud.js';
import './app.js'; // Keep existing app logic
const width = window.innerWidth;
@@ -317,7 +319,8 @@
const config = {
speed: 0.3,
verticalTilt: -20,
scale: Math.min(width, height) / 2.5
scale: Math.min(width, height) / 2.5,
lostStationCoords: [-135, -35] // Point in the Pacific
};
// Major cities for rough location mapping
@@ -345,7 +348,9 @@
{lat: 22.5726, lng: 88.3639, name: "Kolkata"}
];
let totalMemberCount = 0;
let locations = [];
let lostUsers = [];
let institutions = [];
const API_URL = "https://zrvmjrxhokwwmjacyhpq.supabase.co/functions/v1/search-profiles";
@@ -372,11 +377,9 @@
const graticuleGroup = svg.append('g');
const mapGroup = svg.append('g');
const institutionGroup = svg.append('g');
const lostStationGroup = svg.append('g');
const markerGroup = svg.append('g');
// Initialize Cloud Module
initCloud(svg, width, height, tooltip);
// 1. Globe Background
globeGroup.append("path")
.datum({type: "Sphere"})
@@ -393,9 +396,9 @@
// 3. Load World Data
d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
.then(worldData => {
mapGroup.selectAll(".country")
.data(topojson.feature(worldData, worldData.objects.countries).features)
.enter().append("path")
// Optimized: Render countries as a single path
mapGroup.append("path")
.datum(topojson.feature(worldData, worldData.objects.countries))
.attr("class", "country")
.attr("d", path);
@@ -403,119 +406,77 @@
fetchProfiles();
});
// ... (Geocoding helpers omitted for brevity, logic preserved) ...
// Re-implementing simplified helpers for completeness
const geocodeCache = {};
let lastGeocodingTime = 0;
const GEOCODING_DELAY = 1100;
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
async function getCoords(locationStr) {
if (!locationStr || locationStr === "unknown") return getRoughCoords("unknown");
if (geocodeCache[locationStr]) return geocodeCache[locationStr];
try {
const now = Date.now();
if (now - lastGeocodingTime < GEOCODING_DELAY) await sleep(GEOCODING_DELAY - (now - lastGeocodingTime));
lastGeocodingTime = Date.now();
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(locationStr)}&format=json&limit=1`;
const response = await fetch(url, { headers: { 'User-Agent': 'TinyTorch Globe' } });
const data = await response.json();
if (data && data.length > 0) {
const coords = { latitude: parseFloat(data[0].lat), longitude: parseFloat(data[0].lon) };
geocodeCache[locationStr] = coords;
return coords;
}
} catch (e) {}
return getRoughCoords(locationStr);
}
function getRoughCoords(str) {
if (!str) str = "unknown";
let hash = 0;
for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); }
const index = Math.abs(hash) % CITIES.length;
const city = CITIES[index];
return {
latitude: city.lat + (Math.random() - 0.5) * 5,
longitude: city.lng + (Math.random() - 0.5) * 5
};
}
// API Fetch Logic
async function fetchProfiles(query = "") {
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query, page: 0, limit: 20 })
body: JSON.stringify({ query: query, page: 0, limit: 1000 })
});
if (!response.ok) throw new Error("API Error");
const data = await response.json();
if (Array.isArray(data) && data.length > 0) {
if (query) locations = [];
totalMemberCount = data.length;
let mapUsers = [];
let tempLostUsers = [];
locations = data.map(p => {
const roughCoords = (p.latitude && p.longitude)
? { latitude: p.latitude, longitude: p.longitude }
: getRoughCoords(p.location || p.institution || p.username);
data.forEach(p => {
const lat = parseFloat(p.latitude);
const lng = parseFloat(p.longitude);
const isLostLabel = p.location === "Lost at Sea 🌊";
const hasNoLocation = !p.location || p.location === "unknown";
const hasNoCoords = isNaN(lat) || isNaN(lng) || (Math.abs(lat) < 0.001 && Math.abs(lng) < 0.001);
const displayName = (p.display_name && p.display_name.trim()) ? p.display_name.trim() : (p.username ? p.username.split('@')[0].trim() : 'Anonymous');
return {
latitude: roughCoords.latitude,
longitude: roughCoords.longitude,
const userObj = {
user: p.username,
displayName: displayName,
completed: "Member",
institution: Array.isArray(p.institution) ? p.institution.join(", ") : (p.institution || "Independent"),
location: p.location || null
};
if (isLostLabel || (hasNoLocation && hasNoCoords)) {
tempLostUsers.push(userObj);
} else if (!hasNoCoords) {
mapUsers.push({
...userObj,
latitude: lat + (Math.random() - 0.5) * 0.2,
longitude: lng + (Math.random() - 0.5) * 0.2
});
} else {
tempLostUsers.push(userObj);
}
});
locations = mapUsers;
lostUsers = tempLostUsers;
renderMarkers();
renderInstitutions();
data.forEach(async (p, index) => {
const locationString = p.location || p.institution;
if (locationString && !(p.latitude && p.longitude)) {
const coords = await getCoords(locationString);
if (locations[index]) {
locations[index].latitude = coords.latitude;
locations[index].longitude = coords.longitude;
updateMarkerPositions();
updateInstitutionPositions();
}
}
});
renderLostStation();
} else {
if (query) locations = [];
if (query) { locations = []; lostUsers = []; totalMemberCount = 0; }
}
} catch (e) {
locations = [];
lostUsers = [];
totalMemberCount = 0;
}
renderMarkers();
renderInstitutions();
renderLostStation();
if (query) updateSearchDropdown(query);
}
// Render Markers
function renderMarkers() {
// Split Users
const globeUsers = locations.filter(d => d.location !== "Lost at Sea 🌊");
const cloudUsers = locations.filter(d => d.location === "Lost at Sea 🌊");
// Update Cloud
updateCloudUsers(cloudUsers);
// Render Globe Markers
const markers = markerGroup.selectAll('circle.marker')
.data(globeUsers, d => d.user);
.data(locations, d => d.user);
markers.exit().remove();
@@ -533,12 +494,41 @@
d3.select(this).attr("r", 4);
tooltip.style("opacity", 0);
})
.style('opacity', 0)
.transition().duration(300).style('opacity', 1);
document.getElementById('member-count').textContent = `${locations.length + 10} Members`;
document.getElementById('member-count').textContent = `${locations.length} Members on Map`;
document.getElementById('no-location-count').textContent = `${lostUsers.length} without Location (Lost at Sea)`;
updateMarkerPositions();
}
function renderLostStation() {
const station = lostStationGroup.selectAll('g.lost-station')
.data(lostUsers.length > 0 ? [{count: lostUsers.length}] : []);
station.exit().remove();
const enter = station.enter()
.append('g')
.attr('class', 'lost-station')
.style('cursor', 'pointer')
.on("mouseover", function(event, d) {
isHovering = true;
tooltip.html(`<h3>🌊 Sea Station</h3><div class="info-row"><span class="highlight">${d.count}</span> Members drifting here...</div><div class="info-row" style="font-size:10px; color:#888;">(Update location in profile to be found)</div>`);
tooltip.style("left", (event.pageX + 15) + "px").style("top", (event.pageY - 15) + "px").style("opacity", 1);
})
.on("mouseout", function() {
isHovering = false;
tooltip.style("opacity", 0);
});
// Buoy/Station Visual
enter.append('circle').attr('r', 12).attr('fill', 'rgba(46, 204, 113, 0.2)').attr('stroke', '#2ecc71').attr('stroke-width', 1).attr('stroke-dasharray', '2,2');
enter.append('circle').attr('r', 4).attr('fill', '#2ecc71').attr('stroke', '#fff').attr('stroke-width', 1);
updateLostStationPosition();
}
function showTooltip(event, d) {
tooltip.html(`
<h3>${d.displayName}</h3>
@@ -601,9 +591,12 @@
function updateMarkerPositions() {
const markers = markerGroup.selectAll('circle.marker');
if (markers.empty()) return;
const invCenter = projection.invert(center);
markers.each(function(d) {
const coordinate = [d.longitude, d.latitude];
const gdistance = d3.geoDistance(coordinate, projection.invert(center));
const gdistance = d3.geoDistance(coordinate, invCenter);
if (gdistance < 1.57) {
const pos = projection(coordinate);
d3.select(this).attr('cx', pos[0]).attr('cy', pos[1]).style('display', 'block');
@@ -613,12 +606,31 @@
});
}
function updateLostStationPosition() {
const station = lostStationGroup.selectAll('g.lost-station');
if (station.empty()) return;
const invCenter = projection.invert(center);
const coordinate = config.lostStationCoords;
const gdistance = d3.geoDistance(coordinate, invCenter);
if (gdistance < 1.57) {
const pos = projection(coordinate);
station.attr('transform', `translate(${pos[0]},${pos[1]})`).style('display', 'block');
} else {
station.style('display', 'none');
}
}
function updateInstitutionPositions() {
const instMarkers = institutionGroup.selectAll('g.institution-marker');
if (instMarkers.empty()) return;
const invCenter = projection.invert(center);
instMarkers.each(function(d) {
const coordinate = [d.longitude, d.latitude];
const gdistance = d3.geoDistance(coordinate, projection.invert(center));
const gdistance = d3.geoDistance(coordinate, invCenter);
if (gdistance < 1.57) {
const pos = projection(coordinate);
d3.select(this).attr('transform', `translate(${pos[0]},${pos[1]})`).style('display', 'block');
@@ -631,7 +643,6 @@
// Animation
function startAnimation() {
d3.timer(function () {
animateCloud(); // Always animate cloud
if (!isDragging && !isPaused && !isHovering) {
currentRotation[0] += config.speed;
projection.rotate(currentRotation);
@@ -641,14 +652,14 @@
}
function redraw() {
graticuleGroup.selectAll("path").attr("d", path);
mapGroup.selectAll("path").attr("d", path);
globeGroup.selectAll("path").attr("d", path);
graticuleGroup.select("path").attr("d", path);
mapGroup.select("path").attr("d", path);
updateMarkerPositions();
updateInstitutionPositions();
updateLostStationPosition();
}
// Input Handling (Drag, Resize, Search - Simplified for restore)
// Input Handling
const drag = d3.drag()
.on("start", () => { isDragging = true; })
.on("drag", (event) => {
@@ -662,7 +673,6 @@
document.getElementById('rotation-toggle').addEventListener('click', () => { isPaused = !isPaused; });
// ... (Search logic would go here, kept largely as is in previous logic) ...
const searchInput = document.getElementById('user-search');
const searchDropdown = document.getElementById('search-dropdown');
let debounceTimer;
@@ -702,19 +712,10 @@
svg.attr('width', w).attr('height', h);
projection.translate([w/2, h/2]).scale(Math.min(w, h) / 2.5);
center[0] = w/2; center[1] = h/2;
redraw();
});
</script>
// Handle Window Resize
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
svg.attr('width', w).attr('height', h);
projection.translate([w/2, h/2]).scale(Math.min(w, h) / 2.5);
center[0] = w/2;
center[1] = h/2;
// Update static globe background
globeGroup.select("path").attr("d", path);
redraw();
});

View File

@@ -27,7 +27,7 @@
margin-bottom: 50px;
width: 95%;
max-width: 1200px;
background: rgba(255, 255, 255, 0.95);
background: #fff; /* Solid white background */
border: 1px solid #333;
box-shadow: 8px 8px 0px rgba(0,0,0,0.1);
padding: 40px;
@@ -150,9 +150,11 @@
.legend {
margin-top: 20px;
display: flex;
gap: 20px;
flex-direction: column;
gap: 15px;
font-size: 0.8rem;
font-family: 'Verdana', sans-serif;
align-items: flex-start;
}
.legend-item {
@@ -167,6 +169,104 @@
border-radius: 2px;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 2000;
backdrop-filter: blur(4px);
}
.modal-content {
background: #fff;
width: 90%;
max-width: 500px;
border: 2px solid #333;
box-shadow: 10px 10px 0px #ff6600;
padding: 30px;
position: relative;
animation: modalSlide 0.3s ease-out;
}
@keyframes modalSlide {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
font-size: 24px;
cursor: pointer;
font-weight: bold;
}
.modal-title {
font-size: 1.4rem;
margin-bottom: 15px;
color: #ff6600;
border-bottom: 2px dashed #eee;
padding-bottom: 10px;
text-transform: uppercase;
}
.modal-details {
font-family: 'Verdana', sans-serif;
font-size: 0.9rem;
line-height: 1.6;
}
.modal-row {
margin-bottom: 12px;
}
.modal-label {
font-weight: bold;
color: #555;
display: block;
font-size: 0.75rem;
text-transform: uppercase;
}
.show-more {
font-size: 0.7rem;
color: #ff6600;
cursor: pointer;
font-weight: bold;
text-align: center;
display: block;
margin-top: 2px;
text-decoration: underline;
}
.btn-google {
display: block;
width: 100%;
text-align: center;
background: #ff6600;
color: white !important;
padding: 12px;
text-decoration: none;
font-weight: bold;
margin-top: 25px;
border: 2px solid #333;
box-shadow: 4px 4px 0px #333;
transition: all 0.1s;
}
.btn-google:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0px #333;
}
/* Responsive */
@media (max-width: 700px) {
.day { min-height: 60px; }
@@ -202,17 +302,45 @@
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #ff6600;"></div>
<span>General Events</span>
<div style="display: flex; gap: 20px;">
<div class="legend-item">
<div class="legend-color" style="background: #ff6600;"></div>
<span>General Events</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00bcd4;"></div>
<span>Historical Dates</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #8bc34a;"></div>
<span>Community</span>
</div>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00bcd4;"></div>
<span>Historical Dates</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #8bc34a;"></div>
<span>Community</span>
<a href="https://calendar.google.com/calendar/embed?src=a6a109f7e2da7b66503ca3cd8ed1bc18c9755c0cecb99897a3761731f5663852%40group.calendar.google.com&ctz=America%2FNew_York" target="_blank" class="btn-google" style="margin-top: 20px; width: auto; display: inline-block; padding: 10px 20px;">
VIEW FULL CALENDAR IN GOOGLE
</a>
</div>
</div>
<!-- Event Detail Modal -->
<div id="eventModal" class="modal-overlay">
<div class="modal-content">
<span class="modal-close" id="modalClose">&times;</span>
<div id="modalTitle" class="modal-title">Event Title</div>
<div class="modal-details">
<div class="modal-row">
<span class="modal-label">When</span>
<span id="modalWhen"></span>
</div>
<div class="modal-row" id="locationRow">
<span class="modal-label">Where</span>
<span id="modalWhere"></span>
</div>
<div class="modal-row" id="descriptionRow">
<span class="modal-label">Description</span>
<div id="modalDescription"></div>
</div>
<a id="modalLink" href="#" target="_blank" class="btn-google">OPEN IN GOOGLE CALENDAR</a>
</div>
</div>
</div>
@@ -222,49 +350,111 @@
const monthNames = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
const allEvents = [
// December 2025 events (based on user's current context)
// Static historical or community events
const staticEvents = [
{ date: new Date(2025, 11, 1), description: 'Tiny Torch Community is born', type: 'community' },
{ date: new Date(2025, 11, 4), description: 'NeuralIPS 2025 Kickoff', type: 'general' },
{ date: new Date(2025, 11, 8), description: 'Tiny Torch merged with MLSYSBOOK.ai', type: 'community' },
{ date: new Date(2025, 11, 9), description: 'Tiny Torch official launch', type: 'community' },
{ date: new Date(2025, 11, 10), description: 'Ada Lovelace Birthday (1815)', type: 'historical' },
{ date: new Date(2025, 11, 17), description: 'MLSYSBOOK 10k GitHub Stars Celebration via Edge AI Foundation', type: 'community' },
{ date: new Date(2025, 11, 17), description: 'MLSYSBOOK 10k GitHub Stars', type: 'community' },
{ date: new Date(2025, 11, 25), description: 'AI Winter Solstice', type: 'general' },
// Additional AI known dates (historical or general for other months)
{ date: new Date(2022, 10, 30), description: 'ChatGPT Launched', type: 'historical' }, // November 30, 2022
{ date: new Date(1956, 7, 1), description: 'Dartmouth Conference (Birth of AI)', type: 'historical' }, // August 1, 1956
{ date: new Date(1997, 4, 11), description: 'Deep Blue vs Garry Kasparov', type: 'historical' }, // May 11, 1997
{ date: new Date(2016, 2, 9), description: 'AlphaGo vs Lee Sedol', type: 'historical' }, // March 9, 2016
{ date: new Date(2025, 0, 15), description: 'Future AI Summit', type: 'general' }, // January 15, 2025
{ date: new Date(2025, 1, 20), description: 'Quantum AI Workshop', type: 'community' }, // February 20, 2025
{ date: new Date(2025, 8, 1), description: 'Generative AI Conference', type: 'general' }, // Sep 1, 2025
{ date: new Date(2025, 9, 26), description: 'AI Ethics Panel', type: 'community' } // Oct 26, 2025
{ date: new Date(2022, 10, 30), description: 'ChatGPT Launched', type: 'historical' },
{ date: new Date(1956, 7, 1), description: 'Dartmouth Conference', type: 'historical' },
{ date: new Date(1997, 4, 11), description: 'Deep Blue vs Kasparov', type: 'historical' },
{ date: new Date(2016, 2, 9), description: 'AlphaGo vs Lee Sedol', type: 'historical' }
];
let currentYear = 2025; // Initial year (from context: Dec 1, 2025)
let currentMonth = 11; // Initial month (December is 11)
let fetchedEvents = [];
let today = new Date();
if (today.getFullYear() < 2026) {
today = new Date(2026, 1, 27);
}
let currentYear = today.getFullYear();
let currentMonth = today.getMonth();
async function fetchExternalEvents(year, month) {
const timeMin = new Date(year, month, 1).toISOString();
const timeMax = new Date(year, month + 1, 1).toISOString();
const url = `https://ai-engineering-home.vercel.app/api/calendar?timeMin=${timeMin}&timeMax=${timeMax}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('API request failed');
const data = await response.json();
return (data.events || []).map(event => ({
date: new Date(event.start),
endDate: new Date(event.end),
description: event.summary,
fullDescription: event.description,
location: event.location,
type: 'general',
link: event.htmlLink
}));
} catch (error) {
console.error('Error fetching calendar events:', error);
return [];
}
}
function openEventModal(event) {
const modal = document.getElementById('eventModal');
document.getElementById('modalTitle').textContent = event.description;
const startStr = event.date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' });
const endStr = event.endDate ? ' to ' + event.endDate.toLocaleTimeString([], { timeStyle: 'short' }) : '';
document.getElementById('modalWhen').textContent = startStr + endStr;
const locRow = document.getElementById('locationRow');
if (event.location) {
locRow.style.display = 'block';
document.getElementById('modalWhere').textContent = event.location;
} else {
locRow.style.display = 'none';
}
const descRow = document.getElementById('descriptionRow');
if (event.fullDescription) {
descRow.style.display = 'block';
document.getElementById('modalDescription').innerHTML = event.fullDescription.replace(/\n/g, '<br>');
} else {
descRow.style.display = 'none';
}
const linkBtn = document.getElementById('modalLink');
if (event.link) {
linkBtn.style.display = 'inline-block';
linkBtn.href = event.link;
} else {
linkBtn.style.display = 'none';
}
modal.style.display = 'flex';
}
function getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year, month) {
// 0 = Sunday, 1 = Monday, etc.
return new Date(year, month, 1).getDay();
}
function renderCalendar() {
async function renderCalendar() {
const currentMonthYearSpan = document.getElementById('currentMonthYear');
const calendarBody = document.getElementById('calendar-body');
currentMonthYearSpan.textContent = `${monthNames[currentMonth]} ${currentYear}`;
calendarBody.innerHTML = ''; // Clear previous days
fetchedEvents = await fetchExternalEvents(currentYear, currentMonth);
calendarBody.innerHTML = '';
const daysInMonth = getDaysInMonth(currentYear, currentMonth);
const firstDay = getFirstDayOfMonth(currentYear, currentMonth); // 0 for Sunday, 1 for Monday
const firstDay = getFirstDayOfMonth(currentYear, currentMonth);
// Fill leading empty days (from previous month)
const prevMonthDays = getDaysInMonth(currentYear, currentMonth - 1);
for (let i = 0; i < firstDay; i++) {
const dayDiv = document.createElement('div');
@@ -273,35 +463,51 @@
calendarBody.appendChild(dayDiv);
}
// Fill current month days
for (let i = 1; i <= daysInMonth; i++) {
const dayDiv = document.createElement('div');
dayDiv.classList.add('day');
dayDiv.innerHTML = `<div class="day-number">${i}</div>`;
// Add events for this day
const dayEvents = allEvents.filter(event =>
const combinedEvents = [...staticEvents, ...fetchedEvents];
const dayEvents = combinedEvents.filter(event =>
event.date.getFullYear() === currentYear &&
event.date.getMonth() === currentMonth &&
event.date.getDate() === i
);
dayEvents.forEach(event => {
const maxVisible = 2;
dayEvents.slice(0, maxVisible).forEach(event => {
const eventMarker = document.createElement('div');
eventMarker.classList.add('event-marker');
eventMarker.textContent = event.description;
if (event.type) {
eventMarker.classList.add(event.type);
}
if (event.type) eventMarker.classList.add(event.type);
eventMarker.onclick = (e) => {
e.stopPropagation();
openEventModal(event);
};
dayDiv.appendChild(eventMarker);
});
if (dayEvents.length > maxVisible) {
const more = document.createElement('div');
more.classList.add('show-more');
more.textContent = `+ ${dayEvents.length - maxVisible} more`;
more.onclick = (e) => {
// For simplicity, just show the third event in the modal
// or could list them all. Here we'll just open the modal for the first one
// and let the user navigate or list them in the modal.
// Better: open the modal with a list if clicked "more".
// For now, let's just make the whole day clickable if there's more.
openEventModal(dayEvents[maxVisible]);
};
dayDiv.appendChild(more);
}
calendarBody.appendChild(dayDiv);
}
// Fill trailing empty days (from next month)
const totalDaysRendered = firstDay + daysInMonth;
const remainingCells = 42 - totalDaysRendered; // Max 6 rows * 7 days = 42 cells
const remainingCells = 42 - totalDaysRendered;
for (let i = 1; i <= remainingCells; i++) {
const dayDiv = document.createElement('div');
dayDiv.classList.add('day', 'other-month');
@@ -310,7 +516,7 @@
}
}
function changeMonth(delta) {
async function changeMonth(delta) {
currentMonth += delta;
if (currentMonth < 0) {
currentMonth = 11;
@@ -319,16 +525,20 @@
currentMonth = 0;
currentYear++;
}
renderCalendar();
await renderCalendar();
}
// Initial render
renderCalendar();
// Event listeners for navigation buttons
document.getElementById('prevMonth').addEventListener('click', () => changeMonth(-1));
document.getElementById('nextMonth').addEventListener('click', () => changeMonth(1));
document.getElementById('modalClose').addEventListener('click', () => {
document.getElementById('eventModal').style.display = 'none';
});
window.addEventListener('click', (e) => {
const modal = document.getElementById('eventModal');
if (e.target === modal) modal.style.display = 'none';
});
</script>
</body>
</html>

View File

@@ -1,11 +1,8 @@
import { NETLIFY_URL, SUPABASE_URL, SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, getBasePath } from './config.js';
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { updateNavState } from './ui.js?v=2';
import { updateNavState } from './ui.js';
import { closeProfileModal, openProfileModal } from './profile.js';
import { getSession, forceLogin, clearSession } from './state.js?v=2';
import { getSession, forceLogin, clearSession, supabase } from './state.js';
// Initialize Supabase Client
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
export { supabase };
export async function signInWithSocial(provider) {
@@ -267,20 +264,51 @@ export async function handleAuth(e) {
authSubmit.innerHTML = '<div class="spinner"></div>';
try {
let endpoint, body;
if (currentMode === 'forgot') {
endpoint = '/api/auth/reset-password';
body = { email };
} else {
endpoint = currentMode === 'login' ? '/api/auth/login' : '/api/auth/signup';
body = {
// Direct Supabase flow for Signup
if (currentMode === 'signup') {
const redirectUrl = window.location.origin + basePath + '/index.html?action=login&confirmed_email=true';
console.log("Requesting signup with redirect to:", redirectUrl);
const { error } = await supabase.auth.signUp({
email,
password,
redirect_to: window.location.origin + basePath + '/index.html?action=login&confirmed_email=true'
};
options: {
emailRedirectTo: redirectUrl
}
});
if (error) throw error;
closeModal();
showMessageModal(
'Check your Email',
'If you don\'t already have an account, we have sent you an email. Please check your inbox to confirm your signup.'
);
return;
}
// Direct Supabase flow for Password Reset
if (currentMode === 'forgot') {
const redirectUrl = window.location.origin + basePath + '/index.html?action=reset-password';
console.log("Requesting password reset with redirect to:", redirectUrl);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: redirectUrl
});
if (error) throw error;
showMessageModal('Reset Link Sent', 'If an account exists, a reset link has been sent.');
setMode('login');
return;
}
// Keep existing API flow for Login
const endpoint = '/api/auth/login';
const body = {
email,
password,
redirect_to: window.location.origin + basePath + '/index.html?action=login&confirmed_email=true'
};
const url = `${NETLIFY_URL}${endpoint}`;
const response = await fetch(url, {
@@ -294,72 +322,60 @@ export async function handleAuth(e) {
const data = await response.json();
if (currentMode === 'forgot') {
if (response.ok) {
showMessageModal('Reset Link Sent', data.message || 'If an account exists, a reset link has been sent.');
setMode('login');
} else {
throw new Error(data.error || 'Failed to send reset link');
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
if (data.access_token) {
localStorage.setItem("tinytorch_token", data.access_token);
if (data.refresh_token) localStorage.setItem("tinytorch_refresh_token", data.refresh_token);
localStorage.setItem("tinytorch_user", JSON.stringify(data.user));
// Sync Supabase Client so it doesn't trigger SIGNED_OUT
if (data.refresh_token) {
const { error: sessionError } = await supabase.auth.setSession({
access_token: data.access_token,
refresh_token: data.refresh_token
});
if (sessionError) {
console.error("Supabase setSession error during login:", sessionError);
}
} else {
if (!response.ok) {
throw new Error(data.error || (currentMode === 'login' ? 'Login failed' : 'Signup failed'));
}
if (currentMode === 'login') {
if (data.access_token) {
localStorage.setItem("tinytorch_token", data.access_token);
if (data.refresh_token) localStorage.setItem("tinytorch_refresh_token", data.refresh_token);
localStorage.setItem("tinytorch_user", JSON.stringify(data.user));
updateNavState();
// Sync Supabase Client so it doesn't trigger SIGNED_OUT
if (data.refresh_token) {
const { error: sessionError } = await supabase.auth.setSession({
access_token: data.access_token,
refresh_token: data.refresh_token
});
if (sessionError) {
console.error("Supabase setSession error during login:", sessionError);
}
}
// Check Profile Completeness immediately
const { data: profile } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', data.user.id)
.single();
updateNavState();
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
// Check Profile Completeness immediately
const { data: profile } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', data.user.id)
.single();
const params = new URLSearchParams(window.location.search);
const nextParam = params.get('next');
closeModal(); // Always close modal on success
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
if (nextParam) {
// Ensure nextParam is a clean path if it was encoded
const cleanNext = decodeURIComponent(nextParam).split('?')[0];
window.location.href = cleanNext;
return;
}
if (!hasName || !hasInst || !hasLoc) {
window.location.href = basePath + '/profile_setup.html';
return;
}
if (!hasName || !hasInst || !hasLoc) {
window.location.href = basePath + '/profile_setup.html';
return;
}
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'profile') {
closeModal();
openProfileModal();
} else {
window.location.href = basePath + '/dashboard.html';
}
}
if (params.get('action') === 'profile') {
openProfileModal();
} else {
// Signup Success - Show Message Modal
// We close the auth modal first so it doesn't overlap
closeModal();
showMessageModal(
'Check your Email',
'If you don\'t already have an account, we have sent you an email. Please check your inbox to confirm your signup.',
() => {
window.location.href = basePath + '/dashboard.html';
}
);
window.location.href = basePath + '/dashboard.html';
}
}

View File

@@ -1,8 +1,17 @@
// --- CANDLE ANIMATION MODULE ---
const activeLoops = new Set();
export function initCandle(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
// Prevent multiple loops on the same canvas
if (activeLoops.has(canvasId)) {
console.log(`Candle loop already running for ${canvasId}, skipping init.`);
return;
}
activeLoops.add(canvasId);
const ctx = canvas.getContext("2d");
// Coordinate system setup:

View File

@@ -1,81 +1,92 @@
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, SUPABASE_URL, getBasePath } from './config.js';
import { clearSession, supabase } from './state.js';
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
const LOGIN_PAGE = getBasePath() + '/index.html';
const SETUP_PAGE = getBasePath() + '/profile_setup.html';
const DASHBOARD_PAGE = getBasePath() + '/dashboard.html';
(async function guard() {
// 0. HYDRATE SESSION (Fix for Direct Email Login)
const path = window.location.pathname;
// Enhanced path checking for landing page
const isOnIndex = path.endsWith('/') || path.endsWith('index.html') || path === getBasePath() || path === getBasePath() + '/';
const isOnSetupPage = path.includes('profile_setup.html');
const isPublicPage = isOnIndex || path.includes('login') || path.includes('about') || path.includes('contact');
const isProtected = !isPublicPage;
// 0. Get Session
const storedToken = localStorage.getItem("tinytorch_token");
const storedRefresh = localStorage.getItem("tinytorch_refresh_token");
if (storedToken && storedRefresh) {
await supabase.auth.setSession({
access_token: storedToken,
refresh_token: storedRefresh
});
try {
await supabase.auth.setSession({
access_token: storedToken,
refresh_token: storedRefresh
});
} catch (e) {
console.warn("Guard: setSession failed", e);
}
}
// 1. Check Session (Supabase Client)
const { data: { session } } = await supabase.auth.getSession();
let profile = null;
if (session) {
// 2a. Fetch Profile via Client
const { data } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', session.user.id)
.single();
profile = data;
} else {
// 1b. Fallback: Check Token Manually
if (!storedToken) {
// No session, no token -> Redirect
if (!window.location.pathname.includes('index') && !window.location.pathname.includes('login') && !window.location.pathname.includes('about')) {
window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(window.location.pathname);
}
return;
}
// Have token, verify via API
try {
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
headers: { 'Authorization': `Bearer ${storedToken}` }
});
if (!res.ok) {
throw new Error("Token invalid");
}
const data = await res.json();
profile = data.profile; // API returns { profile: {...}, completed_modules: [...] }
} catch (e) {
console.warn("Guard: Token validation failed", e);
if (!window.location.pathname.includes('index') && !window.location.pathname.includes('login')) {
window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(window.location.pathname);
}
return;
}
if (!session && isProtected) {
console.log("🚧 No session on protected page. Redirecting to login...");
window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(path);
return;
}
// 3. The Rules
// Must have ALL three: Name, Institution, Location
if (!session) return; // Public page, no session, we are fine.
// 1. Fetch Profile with Timeout
let profile = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
// Use the API for profile details
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
headers: { 'Authorization': `Bearer ${session.access_token}` },
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
profile = data.profile;
} else if (res.status === 401 || res.status === 404) {
// 401: Token expired, 404: User deleted from DB
console.warn(`Guard: Session invalid or account deleted (${res.status}). Purging...`);
await clearSession();
if (isProtected) {
window.location.href = LOGIN_PAGE + '?action=login';
} else {
// If on a public page, just reload to clear UI state
window.location.reload();
}
return;
}
} catch (e) {
console.warn("Guard: Profile fetch failed or timed out", e);
}
// 2. The Rules
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
const isComplete = hasName && hasInst && hasLoc;
const isOnSetupPage = window.location.pathname.includes('profile_setup');
if (!isComplete && !isOnSetupPage) {
console.log("🚧 Profile incomplete. Redirecting to setup...");
window.location.href = SETUP_PAGE;
}
else if (isComplete && isOnSetupPage) {
// If they are done but try to visit setup, send them to dashboard
window.location.href = getBasePath() + '/dashboard.html';
if (isComplete) {
if (isOnSetupPage) {
console.log("✅ Profile complete. Moving to dashboard...");
window.location.href = DASHBOARD_PAGE;
}
} else {
if (!isOnSetupPage && isProtected) {
console.log("🚧 Profile incomplete. Redirecting to setup...");
window.location.href = SETUP_PAGE;
}
}
})();

View File

@@ -1,5 +1,5 @@
import { SUPABASE_URL, NETLIFY_URL, getBasePath } from './config.js';
import { forceLogin } from './state.js?v=2';
import { forceLogin, getSession, clearSession } from './state.js';
import { initCandle } from './candle.js';
export async function geocodeAndSetCoordinates(location) {
@@ -39,6 +39,21 @@ let candleInitialized = false;
export function openProfileModal() {
const profileOverlay = document.getElementById('profileOverlay');
profileOverlay.classList.add('active');
// Reset delete section
const deleteBtn = document.getElementById('profileDeleteBtn');
const deleteConfirmSection = document.getElementById('deleteConfirmSection');
const deleteConfirmInput = document.getElementById('profileDeleteConfirmInput');
const deleteFinalBtn = document.getElementById('profileDeleteFinalBtn');
if (deleteBtn) deleteBtn.classList.remove('hidden');
if (deleteConfirmSection) deleteConfirmSection.classList.add('hidden');
if (deleteConfirmInput) deleteConfirmInput.value = '';
if (deleteFinalBtn) {
deleteFinalBtn.disabled = true;
deleteFinalBtn.style.opacity = '0.5';
deleteFinalBtn.style.cursor = 'not-allowed';
}
fetchUserProfile();
if (!candleInitialized) {
@@ -49,6 +64,77 @@ export function openProfileModal() {
}
}
export function setupProfileDeleteEvents() {
const deleteBtn = document.getElementById('profileDeleteBtn');
const deleteConfirmSection = document.getElementById('deleteConfirmSection');
const deleteConfirmName = document.getElementById('deleteConfirmName');
const deleteConfirmInput = document.getElementById('profileDeleteConfirmInput');
const deleteFinalBtn = document.getElementById('profileDeleteFinalBtn');
const profileDisplayNameInput = document.getElementById('profileDisplayName');
if (!deleteBtn) return;
deleteBtn.addEventListener('click', () => {
let displayName = profileDisplayNameInput.value.trim();
if (!displayName) {
const { email } = getSession();
displayName = email || 'DELETE';
}
deleteConfirmName.textContent = displayName;
deleteConfirmSection.classList.remove('hidden');
deleteBtn.classList.add('hidden');
});
deleteConfirmInput.addEventListener('input', () => {
const displayName = deleteConfirmName.textContent.trim();
if (deleteConfirmInput.value.trim() === displayName) {
deleteFinalBtn.disabled = false;
deleteFinalBtn.style.opacity = '1';
deleteFinalBtn.style.cursor = 'pointer';
} else {
deleteFinalBtn.disabled = true;
deleteFinalBtn.style.opacity = '0.5';
deleteFinalBtn.style.cursor = 'not-allowed';
}
});
deleteFinalBtn.addEventListener('click', async () => {
const confirmResult = confirm("Are you absolutely sure? This will delete all your data and access.");
if (!confirmResult) return;
deleteFinalBtn.disabled = true;
deleteFinalBtn.textContent = 'Deleting...';
const token = localStorage.getItem("tinytorch_token");
try {
const response = await fetch(`${SUPABASE_URL}/delete-account`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
let errData = {};
try {
errData = await response.json();
} catch(e) {}
throw new Error(errData.error || 'Failed to delete account');
}
alert("Your account has been deleted.");
await clearSession();
window.location.href = getBasePath() + '/index.html';
} catch (error) {
console.error("Delete account error:", error);
alert("Error deleting account: " + error.message);
deleteFinalBtn.disabled = false;
deleteFinalBtn.textContent = 'Permanently Delete My Account';
}
});
}
export function closeProfileModal() {
const profileOverlay = document.getElementById('profileOverlay');
profileOverlay.classList.remove('active');

View File

@@ -1,4 +1,16 @@
import { getBasePath } from './config.js';
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY } from './config.js';
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, {
auth: {
flowType: 'pkce', // Prefer PKCE for security, or keep 'implicit' if standard for this app
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
});
export { supabase };
// State Management
export function getSession() {
@@ -12,10 +24,46 @@ export function getSession() {
return { token, email, isLoggedIn: !!token };
}
export function clearSession() {
export async function clearSession() {
console.log("🧹 Aggressively clearing session and cookies...");
try {
await supabase.auth.signOut();
} catch (e) {
console.warn("Supabase signOut error during clearSession:", e);
}
// 1. Explicitly remove our own keys
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
sessionStorage.removeItem("tinytorch_location_checked");
// 2. Clear all Supabase and auth-related keys from localStorage
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.includes("supabase") ||
key.includes("auth-token") ||
key.startsWith("sb-")
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(k => localStorage.removeItem(k));
// 3. Clear all auth-related cookies
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
// Clear for all common paths and subdomains
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
}
console.log("✨ Session cleared.");
}
export function forceLogin() {

View File

@@ -441,6 +441,33 @@ export function injectStyles() {
background: #b71c1c;
}
.profile-delete-btn {
width: 100%;
padding: 12px;
background: transparent;
color: #d32f2f;
border: 1px solid #d32f2f;
border-radius: 10px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.profile-delete-btn:hover:not(:disabled) {
background: #fff5f5;
}
#profileDeleteFinalBtn {
background: #d32f2f;
color: white;
border: none;
}
#profileDeleteFinalBtn:hover:not(:disabled) {
background: #b71c1c;
}
/* --- Mobile Optimizations --- */
@media (max-width: 768px) {
/* Sidebar becomes a bottom sheet */

View File

@@ -1,5 +1,5 @@
import { getBasePath, NETLIFY_URL } from './config.js';
import { getSession } from './state.js?v=2';
import { getSession } from './state.js';
export function updateNavState() {
const { isLoggedIn, email: userEmail } = getSession();
@@ -132,15 +132,15 @@ export function renderLayout() {
<div class="profile-overlay" id="profileOverlay">
<div class="profile-modal" style="max-width: 850px; width: 95%;">
<button class="profile-close" id="profileClose">&times;</button>
<div style="display: flex; gap: 30px; align-items: stretch;">
<div style="flex: 1;">
<h2 class="profile-title">Your Profile</h2>
<div style="display: flex; gap: 40px; align-items: flex-start; position: relative;">
<div style="flex: 1; min-width: 0;">
<h2 class="profile-title" style="text-align: left; margin-bottom: 25px;">Your Profile</h2>
<form id="profileForm">
<div class="profile-form-group">
<label for="profileDisplayName" class="profile-label">Display Name:</label>
<input type="text" class="profile-input" id="profileDisplayName" placeholder="Display Name">
</div>
<!-- ... rest of the form ... -->
<div class="profile-form-group">
<label for="profileFullName" class="profile-label">Full Name:</label>
<input type="text" class="profile-input" id="profileFullName" placeholder="Your Full Name">
@@ -180,12 +180,26 @@ export function renderLayout() {
<button type="submit" class="profile-submit" id="profileSubmit">Update Profile</button>
<button type="button" class="profile-logout-btn" id="profileLogoutBtn">Logout</button>
<div class="profile-danger-zone" style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #fee2e2;">
<h3 style="color: #d32f2f; font-size: 1rem; margin-bottom: 10px;">Danger Zone</h3>
<p style="font-size: 0.85rem; color: #666; margin-bottom: 15px;">Deleting your account is permanent and cannot be undone.</p>
<button type="button" class="profile-delete-btn" id="profileDeleteBtn">Delete Account</button>
<div id="deleteConfirmSection" class="hidden" style="margin-top: 15px; padding: 15px; background: #fff5f5; border-radius: 10px; border: 1px solid #fecaca;">
<p style="font-size: 0.85rem; color: #b91c1c; margin-bottom: 10px;">Type your display name <strong id="deleteConfirmName"></strong> to confirm:</p>
<input type="text" class="profile-input" id="profileDeleteConfirmInput" placeholder="Enter display name">
<button type="button" class="profile-delete-btn" id="profileDeleteFinalBtn" style="margin-top: 10px; opacity: 0.5; cursor: not-allowed;" disabled>Permanently Delete My Account</button>
</div>
</div>
</form>
</div>
<!-- Flame Side -->
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 180px; border-left: 1px solid #f0f0f0; background: rgba(252, 252, 252, 0.5);">
<canvas id="profileCandleCanvas" width="16" height="24" style="width: 150px; height: auto; image-rendering: pixelated; filter: drop-shadow(4px 4px 0px rgba(0,0,0,0.05));"></canvas>
<div style="flex: 0 0 220px; min-height: 450px; border-left: 1px solid #f0f0f0; background: rgba(252, 252, 252, 0.5); border-radius: 0 16px 16px 0; align-self: stretch; position: relative;">
<div style="position: sticky; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px;">
<canvas id="profileCandleCanvas" width="16" height="24" style="width: 140px; height: auto; image-rendering: pixelated; filter: drop-shadow(0 10px 20px rgba(255,102,0,0.2));"></canvas>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,78 @@
{
"name": "tinytorch-community-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tinytorch-community-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.42.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "tinytorch-community-tests",
"version": "1.0.0",
"description": "E2E tests for Tiny Torch Community",
"scripts": {
"test": "npx playwright test",
"test:ui": "npx playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.42.0"
}
}

View File

@@ -0,0 +1,21 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:8000/community/',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -307,9 +307,10 @@
</script>
<script type="module">
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, SUPABASE_URL, getBasePath } from './modules/config.js';
import { initCandle } from './modules/candle.js';
import { supabase } from './modules/state.js';
import './modules/guard.js'; // Guard handles access control and completeness redirects
// Initialize Candle
initCandle('candleCanvas');
@@ -380,65 +381,48 @@
const depthSegments = 100;
const geometry = new THREE.PlaneGeometry(width, depth, widthSegments, depthSegments);
geometry.rotateX(-Math.PI / 2);
const count = geometry.attributes.position.count;
geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(count * 3), 3));
const material = new THREE.MeshBasicMaterial({ vertexColors: true, wireframe: true, transparent: true, opacity: 0.4 });
const terrain = new THREE.Mesh(geometry, material);
const terrainMat = new THREE.ShaderMaterial({
vertexShader: document.getElementById('terrain-vs').textContent,
fragmentShader: document.getElementById('terrain-fs').textContent,
uniforms: {
uTime: { value: 0 },
uNoiseScale: { value: 0.15 },
uHeightScale: { value: 4.0 }
},
wireframe: true,
transparent: true
});
const terrain = new THREE.Mesh(geometry, terrainMat);
scene.add(terrain);
const solidMat = new THREE.MeshBasicMaterial({ color: 0xf5f5f0, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
// Solid base layer
const solidMat = new THREE.ShaderMaterial({
vertexShader: document.getElementById('terrain-vs').textContent,
fragmentShader: document.getElementById('terrain-fs').textContent,
uniforms: {
uTime: { value: 0 },
uNoiseScale: { value: 0.15 },
uHeightScale: { value: 4.0 }
},
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
});
const terrainSolid = new THREE.Mesh(geometry, solidMat);
scene.add(terrainSolid);
const noiseScale = 0.15;
const heightScale = 4.0;
const positionAttribute = geometry.attributes.position;
const colorAttribute = geometry.attributes.color;
const originalPositions = positionAttribute.array.slice();
const colorWater = new THREE.Color(0x3498db);
const colorSand = new THREE.Color(0xf1c40f);
const colorGrass = new THREE.Color(0x2ecc71);
const colorRock = new THREE.Color(0xe74c3c);
function getColorForHeight(h) {
let finalColor = new THREE.Color();
if (h < -0.2) finalColor.copy(colorWater);
else if (h < 0.1) finalColor.lerpColors(colorWater, colorSand, (h + 0.2) / 0.3);
else if (h < 0.6) finalColor.lerpColors(colorSand, colorGrass, (h - 0.1) / 0.5);
else finalColor.lerpColors(colorGrass, colorRock, Math.min((h - 0.6) / 0.9, 1));
return finalColor;
}
function updateScene() {
skyMesh.material.uniforms.uTime.value += 0.01;
const time = skyMesh.material.uniforms.uTime.value;
const flyOverY = time * 2; // Simpler flyover
for (let i = 0; i < count; i++) {
const ix = i * 3;
const iz = i * 3 + 2;
const x = originalPositions[ix];
const z = originalPositions[iz];
const vZ = z - flyOverY;
const noiseVal = Noise.perlin2(x * noiseScale, vZ * noiseScale);
let height = noiseVal * heightScale;
if (height < -1) height = -1;
positionAttribute.setY(i, height);
const c = getColorForHeight(noiseVal);
c.multiplyScalar(0.9);
colorAttribute.setXYZ(i, c.r, c.g, c.b);
}
positionAttribute.needsUpdate = true;
colorAttribute.needsUpdate = true;
}
function animate() {
requestAnimationFrame(animate);
updateScene();
const time = performance.now() * 0.001;
skyMesh.material.uniforms.uTime.value = time;
terrain.material.uniforms.uTime.value = time;
terrainSolid.material.uniforms.uTime.value = time;
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
@@ -447,17 +431,7 @@
// --- AUTH & PROFILE LOGIC ---
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
// --- 0. HYDRATE SESSION ---
const storedToken = localStorage.getItem("tinytorch_token");
const storedRefresh = localStorage.getItem("tinytorch_refresh_token");
if (storedToken && storedRefresh) {
await supabase.auth.setSession({
access_token: storedToken,
refresh_token: storedRefresh
});
}
// supabase is imported from state.js above
// --- 1. AUTOCOMPLETE LOGIC ---
const locInput = document.getElementById('locationInput');
@@ -491,63 +465,27 @@
}, 300);
});
// --- 2. PRE-FILL LOGIC (WITH FALLBACK) ---
async function loadUser() {
const { data: { user } } = await supabase.auth.getUser();
let userProfile = null;
let userId = null;
// --- 2. PRE-FILL LOGIC ---
async function loadUser(user) {
if (!user) return;
if(!user) {
// Fallback: Check Token Manually if getUser failed (e.g. missing refresh token)
if (storedToken) {
try {
// Fetch details directly to confirm token and get user ID
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
headers: { 'Authorization': `Bearer ${storedToken}` }
});
if (res.ok) {
const data = await res.json();
// Construct a minimal user object or just use the profile data
// We need userId to query 'profiles' or we can just use the data returned
userProfile = data.profile;
userId = data.profile.id; // Assuming profile has ID, or we get it from token decoding (complicated without lib)
// Actually, get-profile-details returns the profile! We can use it directly.
} else {
// Token invalid
window.location.href = getBasePath() + '/index.html';
return;
}
} catch(e) {
window.location.href = getBasePath() + '/index.html';
return;
}
} else {
window.location.href = getBasePath() + '/index.html';
return;
}
} else {
userId = user.id;
}
// 1. Try to grab name from Google/GitHub metadata (if available from getUser)
if(user && user.user_metadata) {
// 1. Try to grab name from Google/GitHub metadata
if(user.user_metadata) {
if(user.user_metadata.full_name) {
document.getElementById('fullName').value = user.user_metadata.full_name;
const parts = user.user_metadata.full_name.split(' ');
if(parts.length > 0) document.getElementById('displayName').value = parts[0];
if(parts.length > 0 && !document.getElementById('displayName').value) {
document.getElementById('displayName').value = parts[0];
}
}
if(user.user_metadata.name && !document.getElementById('fullName').value) {
document.getElementById('fullName').value = user.user_metadata.name;
}
}
// 2. Load Profile Data (Either from fallback or DB)
if (userProfile) {
populateForm(userProfile);
} else if (userId) {
const { data: profile } = await supabase.from('profiles').select('*').eq('id', userId).single();
if(profile) populateForm(profile);
}
// 2. Load Profile Data from DB
const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single();
if(profile) populateForm(profile);
}
function populateForm(profile) {
@@ -572,7 +510,17 @@
}
}
loadUser();
// Listen for auth state to trigger load
supabase.auth.onAuthStateChange((event, session) => {
if (session && session.user) {
loadUser(session.user);
}
});
// Initial load check
supabase.auth.getUser().then(({ data: { user } }) => {
if (user) loadUser(user);
});
// --- 3. SUBMIT LOGIC ---
document.getElementById('setupForm').addEventListener('submit', async (e) => {
@@ -582,8 +530,11 @@
btn.disabled = true;
const { data: { session } } = await supabase.auth.getSession();
// Use stored token if session is missing (Fallback)
const tokenToUse = session ? session.access_token : storedToken;
if (!session) {
alert("Session lost. Please log in again.");
window.location.href = getBasePath() + '/index.html?action=login';
return;
}
const instVal = document.getElementById('institution').value.trim();
const institutions = instVal ? [instVal] : ["Independent"];
@@ -595,7 +546,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenToUse}`
'Authorization': `Bearer ${session.access_token}`
},
body: JSON.stringify({
display_name: document.getElementById('displayName').value,
@@ -612,6 +563,8 @@
});
if(!res.ok) throw new Error("Save failed");
// Success! Guard will now see profile as complete and allow dashboard access
window.location.href = getBasePath() + '/dashboard.html';
} catch(err) {

View File

@@ -0,0 +1,77 @@
const { test, expect } = require('@playwright/test');
test.describe('Authentication Flow', () => {
test('Landing page should be accessible without login', async ({ page }) => {
await page.goto('index.html');
await expect(page).toHaveTitle(/AI History Landscape|The Tiny Torch/);
// Check if login button is visible
const authBtn = page.locator('#authBtn');
await expect(authBtn).toBeVisible();
});
test('Protected page (dashboard) should redirect to index with login action', async ({ page }) => {
await page.goto('dashboard.html');
// Guard should redirect to index.html?action=login&next=...
await expect(page).toHaveURL(/index\.html\?action=login/);
// Auth modal should be active
const authOverlay = page.locator('#authOverlay');
await expect(authOverlay).toHaveClass(/active/);
});
test('Manual Email Login - UI Interaction', async ({ page }) => {
await page.goto('index.html?action=login');
// Switch to login mode if it defaults to signup
const toggle = page.locator('#authToggle');
const title = page.locator('#authTitle');
// Wait for modal to be ready
await expect(page.locator('#authOverlay')).toHaveClass(/active/);
if ((await title.innerText()) === 'Create Account') {
await toggle.click();
}
await expect(title).toHaveText('Login');
// Fill in credentials (using dummy ones for UI test)
await page.fill('#authEmail', 'test@example.com');
await page.fill('#authPassword', 'password123');
// Click login
const loginBtn = page.locator('#authSubmit');
await expect(loginBtn).toHaveText('Login');
});
test('Logout should clear session and redirect to index', async ({ page }) => {
// Manually set a mock session
await page.goto('index.html');
await page.evaluate(() => {
localStorage.setItem('tinytorch_token', 'mock-token');
localStorage.setItem('tinytorch_user', JSON.stringify({ email: 'test@example.com' }));
});
await page.reload();
// Open profile/logout modal
await page.click('#authBtn');
// Listen for dialog (confirm logout)
page.on('dialog', dialog => dialog.accept());
const logoutBtn = page.locator('#profileLogoutBtn');
await expect(logoutBtn).toBeVisible();
// Wait for navigation after clicking logout
await Promise.all([
page.waitForURL(/index\.html/),
logoutBtn.click()
]);
// Verify session is completely purged
const token = await page.evaluate(() => localStorage.getItem('tinytorch_token'));
expect(token).toBeNull();
});
});

View File

@@ -0,0 +1,6 @@
{
"testUser": {
"email": "user@example.com",
"password": "password123"
}
}

View File

@@ -0,0 +1,48 @@
const { test, expect } = require('@playwright/test');
const { testUser } = require('./credentials.json');
test.describe('User Account Lifecycle', () => {
test('Login and Navigate Profile', async ({ page }) => {
// 1. Navigate to login
await page.goto('index.html?action=login');
// Ensure we are in login mode
const title = page.locator('#authTitle');
if ((await title.innerText()) === 'Create Account') {
await page.click('#authToggle');
}
// 2. Perform Login
await page.fill('#authEmail', testUser.email);
await page.fill('#authPassword', testUser.password);
// We expect a redirect after login (to dashboard or profile_setup)
await Promise.all([
page.waitForURL(/dashboard\.html|profile_setup\.html/),
page.click('#authSubmit')
]);
console.log('✅ Logged in successfully');
// 3. Verify we can open the profile modal
// Note: If redirect went to profile_setup.html, authBtn might already be active
await page.click('#authBtn');
const profileOverlay = page.locator('#profileOverlay');
await expect(profileOverlay).toHaveClass(/active/);
// 4. Check if the display name is loaded correctly
const displayNameInput = page.locator('#profileDisplayName');
await expect(displayNameInput).not.toHaveValue('');
console.log('✅ Profile data loaded correctly');
// 5. Navigate to Dashboard
await page.goto('dashboard.html');
await expect(page).toHaveURL(/dashboard\.html/);
console.log('✅ Navigation verified');
});
});

View File

@@ -967,11 +967,11 @@ Data Samples × Projection Matrix = Projected Data
```
Matrix Multiplication Process:
A (2×3) B (3×2) C (2×2)
┌ ┐ ┌ ┐ ┌ ┐
│ 1 2 3 │ │ 7 8 │ │ 1×7+2×9+3×1 │ ┌ ┐
│ │ × │ 9 1 │ = │ │ = │ 28 16 │
│ 4 5 6 │ │ 1 2 │ │ 4×7+5×9+6×1 │ │ 79 49 │
└ ┘ └ ┘ └ ┘ └ ┘
┌ ┐ ┌ ┐ ┌
│ 1 2 3 │ │ 7 8 │ │ 1×7+2×9+3×1 1*8+2*1+3*2 │ ┌ ┐
│ │ × │ 9 1 │ = │ │ = │ 28 16 │
│ 4 5 6 │ │ 1 2 │ │ 4×7+5×9+6×1 4*8+1*5+6*2 │ │ 79 49 │
└ ┘ └ ┘ └ ┘ └ ┘
Computation Breakdown:
C[0,0] = A[0,:] · B[:,0] = [1,2,3] · [7,9,1] = 1×7 + 2×9 + 3×1 = 28