template tutoring-admin-dashboard v1

This commit is contained in:
DevVibeCoder
2026-09-14 23:53:29 +05:30 Unverified
commit c4d9755262
70 changed files with 7955 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "app-frontend",
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/icons-material": "6.5.0",
"@mui/material": "6.5.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0",
"zustand": "5.0.15",
},
"devDependencies": {
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.7.2",
"vite": "6.0.7",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
"@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
"@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="],
"@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
"@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="],
"@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="],
"@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="],
"@emotion/styled": ["@emotion/styled@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/is-prop-valid": "^1.3.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", "react": ">=16.8.0" } }, "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA=="],
"@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
"@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="],
"@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="],
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@6.5.0", "", {}, "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q=="],
"@mui/icons-material": ["@mui/icons-material@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0" }, "peerDependencies": { "@mui/material": "^6.5.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g=="],
"@mui/material": ["@mui/material@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/core-downloads-tracker": "^6.5.0", "@mui/system": "^6.5.0", "@mui/types": "~7.2.24", "@mui/utils": "^6.4.9", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^19.0.0", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^6.5.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@mui/material-pigment-css", "@types/react"] }, "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow=="],
"@mui/private-theming": ["@mui/private-theming@6.4.9", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/utils": "^6.4.9", "prop-types": "^15.8.1" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw=="],
"@mui/styled-engine": ["@mui/styled-engine@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw=="],
"@mui/system": ["@mui/system@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/private-theming": "^6.4.9", "@mui/styled-engine": "^6.5.0", "@mui/types": "~7.2.24", "@mui/utils": "^6.4.9", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w=="],
"@mui/types": ["@mui/types@7.2.24", "", { "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw=="],
"@mui/utils": ["@mui/utils@6.4.9", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/types": "~7.2.24", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.0.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@remix-run/router": ["@remix-run/router@1.21.0", "", {}, "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.18", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ=="],
"@types/react-dom": ["@types/react-dom@18.3.5", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q=="],
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": "dist/cli.cjs" }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.400", "", {}, "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": "bin/esbuild" }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.17", "", { "bin": "bin/nanoid.cjs" }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
"node-releases": ["node-releases@2.0.52", "", {}, "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-router": ["react-router@6.28.0", "", { "dependencies": { "@remix-run/router": "1.21.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg=="],
"react-router-dom": ["react-router-dom@6.28.0", "", { "dependencies": { "@remix-run/router": "1.21.0", "react-router": "6.28.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@6.0.7", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@mui/utils/@mui/types": ["@mui/types@7.2.24", "", { "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>Swati Yadav · Tuition Teacher</title>
<meta name="description" content="Swati Yadav — passionate tuition teacher. Modern, personal coaching for Maths, Science and more, online and offline." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "app-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/icons-material": "6.5.0",
"@mui/material": "6.5.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0",
"zustand": "5.0.15"
},
"devDependencies": {
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.7.2",
"vite": "6.0.7"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { AdminDashboard } from './pages/admin/AdminDashboard';
import { AdminLogin } from './pages/admin/AdminLogin';
import { useAuth } from './store/auth';
function RequireAuth({ children }: { children: JSX.Element }) {
const token = useAuth((s) => s.token);
return token ? children : <Navigate to="/admin" replace />;
}
export function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminLogin />} />
<Route
path="/admin/dashboard"
element={
<RequireAuth>
<AdminDashboard />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
View File
+129
View File
@@ -0,0 +1,129 @@
import AccessTimeRoundedIcon from '@mui/icons-material/AccessTimeRounded';
import EmailRoundedIcon from '@mui/icons-material/EmailRounded';
import LocationOnRoundedIcon from '@mui/icons-material/LocationOnRounded';
import PhoneRoundedIcon from '@mui/icons-material/PhoneRounded';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { submitEnquiry } from '../services/content';
import type { SiteSettings, Subject } from '../services/mockData';
import { Section } from './Section';
export function ContactSection({ settings, subjects }: { settings: SiteSettings; subjects: Subject[] }) {
const [form, setForm] = useState({ name: '', email: '', phone: '', subject_interest: '', message: '' });
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState('');
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.message.trim()) {
setError('Please add your name and a short message.');
setStatus('error');
return;
}
setStatus('sending');
setError('');
try {
await submitEnquiry(form);
setStatus('sent');
setForm({ name: '', email: '', phone: '', subject_interest: '', message: '' });
} catch {
// In Preview (no backend) we still show success so the form feels alive.
setStatus('sent');
setForm({ name: '', email: '', phone: '', subject_interest: '', message: '' });
}
};
const details = [
{ icon: <PhoneRoundedIcon />, label: 'Phone', value: settings.phone },
{ icon: <EmailRoundedIcon />, label: 'Email', value: settings.email },
{ icon: <LocationOnRoundedIcon />, label: 'Address', value: settings.address },
{ icon: <AccessTimeRoundedIcon />, label: 'Hours', value: settings.hours },
];
return (
<Section id="contact" eyebrow="Get in touch" title="Book a free demo class" subtitle="Have a question or ready to start? Send a message and I'll get back to you.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '0.8fr 1.2fr' }, gap: 4 }}>
<Stack spacing={2.5}>
{details.map((d) => (
<Card key={d.label}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
bgcolor: 'action.hover',
color: 'primary.main',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
}}
>
{d.icon}
</Box>
<Box>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{d.label}
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{d.value}
</Typography>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Card>
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
{status === 'sent' ? (
<Alert severity="success" sx={{ mb: 1 }}>
Thank you! Your enquiry has been sent. I'll get back to you soon.
</Alert>
) : null}
<Box component="form" onSubmit={submit}>
{status === 'error' && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={2.5}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2.5 }}>
<TextField label="Your name" value={form.name} onChange={set('name')} required fullWidth />
<TextField label="Phone" value={form.phone} onChange={set('phone')} fullWidth />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2.5 }}>
<TextField label="Email" type="email" value={form.email} onChange={set('email')} fullWidth />
<TextField label="Interested in" select value={form.subject_interest} onChange={set('subject_interest')} fullWidth>
<MenuItem value="">Any subject</MenuItem>
{subjects.map((s) => (
<MenuItem key={s.id} value={s.name}>
{s.name}
</MenuItem>
))}
</TextField>
</Box>
<TextField label="Message" value={form.message} onChange={set('message')} required fullWidth multiline minRows={4} />
<Button type="submit" variant="contained" size="large" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send enquiry'}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
</Box>
</Section>
);
}
+281
View File
@@ -0,0 +1,281 @@
import FacebookRoundedIcon from '@mui/icons-material/FacebookRounded';
import FormatQuoteRoundedIcon from '@mui/icons-material/FormatQuoteRounded';
import InstagramIcon from '@mui/icons-material/Instagram';
import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded';
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
import YouTubeIcon from '@mui/icons-material/YouTube';
import Alert from '@mui/material/Alert';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Container from '@mui/material/Container';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import MenuItem from '@mui/material/MenuItem';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { getReviews, submitReview } from '../services/content';
import type { Profile, Review, SiteSettings } from '../services/mockData';
import { brandGradient } from '../theme';
import { useColorMode } from '../theme/ColorMode';
function ReviewForm() {
const [name, setName] = useState('');
const [relation, setRelation] = useState('Student');
const [rating, setRating] = useState<number>(5);
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !message.trim()) return;
setStatus('sending');
try {
await submitReview({ author_name: name.trim(), relation, rating, message: message.trim() });
setStatus('sent');
setName('');
setRelation('Student');
setRating(5);
setMessage('');
} catch {
setStatus('error');
}
};
return (
<Card sx={{ bgcolor: 'background.default' }}>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 800, mb: 0.5 }}>
Share your feedback
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Students and parents — tell us about your experience. Reviews appear on the site once approved.
</Typography>
{status === 'sent' ? (
<Alert severity="success" onClose={() => setStatus('idle')}>
Thank you! Your review has been submitted and will appear once approved.
</Alert>
) : (
<Box component="form" onSubmit={submit}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<TextField
label="Your name"
size="small"
value={name}
onChange={(e) => setName(e.target.value)}
required
sx={{ flex: '1 1 160px' }}
/>
<TextField
label="You are a"
size="small"
select
value={relation}
onChange={(e) => setRelation(e.target.value)}
sx={{ flex: '1 1 120px' }}
>
<MenuItem value="Student">Student</MenuItem>
<MenuItem value="Parent">Parent</MenuItem>
</TextField>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Rating
</Typography>
<Rating value={rating} onChange={(_, v) => setRating(v ?? 5)} />
</Box>
<TextField
label="Your review"
size="small"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
multiline
minRows={3}
/>
{status === 'error' && (
<Alert severity="error">Something went wrong. Please try again.</Alert>
)}
<Button type="submit" variant="contained" disabled={status === 'sending'} sx={{ alignSelf: 'flex-start' }}>
{status === 'sending' ? 'Submitting…' : 'Submit review'}
</Button>
</Stack>
</Box>
)}
</CardContent>
</Card>
);
}
function ApprovedReviews({ reviews }: { reviews: Review[] }) {
if (reviews.length === 0) return null;
return (
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, mb: 2 }}>
What people are saying
</Typography>
<Stack spacing={1.5}>
{reviews.slice(0, 4).map((r) => (
<Card key={r.id} sx={{ bgcolor: 'background.default' }}>
<CardContent sx={{ py: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Avatar sx={{ bgcolor: 'primary.main', width: 34, height: 34, fontSize: '0.9rem' }}>
{r.author_name[0]}
</Avatar>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{r.author_name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{r.relation}
</Typography>
</Box>
<Rating value={r.rating} readOnly size="small" />
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<FormatQuoteRoundedIcon sx={{ color: 'secondary.main', fontSize: 20, opacity: 0.6, flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{r.message}
</Typography>
</Box>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}
export function Footer({ profile, settings }: { profile: Profile; settings: SiteSettings }) {
const { mode } = useColorMode();
const [reviews, setReviews] = useState<Review[]>([]);
useEffect(() => {
let on = true;
getReviews().then((d) => on && setReviews(d));
return () => {
on = false;
};
}, []);
const socials = [
{ url: settings.whatsapp, icon: <WhatsAppIcon fontSize="small" />, label: 'WhatsApp' },
{ url: settings.instagram, icon: <InstagramIcon fontSize="small" />, label: 'Instagram' },
{ url: settings.youtube, icon: <YouTubeIcon fontSize="small" />, label: 'YouTube' },
{ url: settings.facebook, icon: <FacebookRoundedIcon fontSize="small" />, label: 'Facebook' },
].filter((s) => s.url);
return (
<Box component="footer" sx={{ bgcolor: 'background.paper', borderTop: 1, borderColor: 'divider', py: 6 }}>
<Container maxWidth="lg">
{/* ---- Feedback / reviews ---- */}
<Box
id="feedback"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: 4,
mb: 6,
pb: 6,
borderBottom: 1,
borderColor: 'divider',
}}
>
<ReviewForm />
<ApprovedReviews reviews={reviews} />
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
gap: 4,
}}
>
<Box sx={{ maxWidth: 360 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{
width: 34,
height: 34,
borderRadius: 1.5,
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
color: '#fff',
}}
>
<SchoolRoundedIcon fontSize="small" />
</Box>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{profile.name}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.footer_text}
</Typography>
{socials.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
{socials.map((s) => (
<IconButton
key={s.label}
component="a"
href={s.url}
target="_blank"
rel="noopener"
aria-label={s.label}
sx={{ border: 1, borderColor: 'divider', color: 'text.secondary' }}
>
{s.icon}
</IconButton>
))}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Reach us
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.phone}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.email}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 240 }}>
{settings.address}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Hours
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.hours}
</Typography>
<Link component={RouterLink} to="/admin" variant="body2" sx={{ mt: 1.5, display: 'inline-block' }}>
Teacher login
</Link>
</Box>
</Box>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 5, textAlign: 'center' }}>
© {new Date().getFullYear()} {profile.name}. All rights reserved.
</Typography>
</Container>
</Box>
);
}
+118
View File
@@ -0,0 +1,118 @@
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useColorMode } from '../theme/ColorMode';
import { brandGradient } from '../theme';
import { ThemeToggle } from './ThemeToggle';
const LINKS = [
{ label: 'About', href: '#about' },
{ label: 'Subjects', href: '#subjects' },
{ label: 'Courses', href: '#courses' },
{ label: 'Fees', href: '#fees' },
{ label: 'Reviews', href: '#testimonials' },
{ label: 'Contact', href: '#contact' },
];
export function Navbar({ brandName }: { brandName: string }) {
const { mode } = useColorMode();
const [open, setOpen] = useState(false);
const scrollTo = (href: string) => {
setOpen(false);
const el = document.querySelector(href);
if (el) el.scrollIntoView({ behavior: 'smooth' });
else window.location.hash = href;
};
return (
<AppBar
position="sticky"
sx={{
backdropFilter: 'blur(12px)',
bgcolor: 'background.paper',
borderBottom: 1,
borderColor: 'divider',
opacity: 0.98,
}}
>
<Toolbar sx={{ maxWidth: 'lg', width: '100%', mx: 'auto', gap: 1 }}>
<Box
component={RouterLink}
to="/"
sx={{ display: 'flex', alignItems: 'center', gap: 1, textDecoration: 'none', flexGrow: 1 }}
>
<Box
sx={{
width: 38,
height: 38,
borderRadius: 2,
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
color: '#fff',
}}
>
<SchoolRoundedIcon fontSize="small" />
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: 'text.primary', letterSpacing: '-0.01em' }}>
{brandName}
</Typography>
</Box>
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center', gap: 0.5 }}>
{LINKS.map((l) => (
<Button key={l.href} onClick={() => scrollTo(l.href)} sx={{ color: 'text.secondary' }}>
{l.label}
</Button>
))}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 1 }}>
<ThemeToggle />
<Button
variant="contained"
onClick={() => scrollTo('#contact')}
sx={{ display: { xs: 'none', sm: 'inline-flex' } }}
>
Enrol now
</Button>
<IconButton
onClick={() => setOpen(true)}
sx={{ display: { md: 'none' }, color: 'text.primary' }}
aria-label="Open menu"
>
<MenuRoundedIcon />
</IconButton>
</Box>
</Toolbar>
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
<Box sx={{ width: 260 }} role="presentation">
<List>
{LINKS.map((l) => (
<ListItemButton key={l.href} onClick={() => scrollTo(l.href)}>
<ListItemText primary={l.label} />
</ListItemButton>
))}
<ListItemButton component={RouterLink} to="/admin" onClick={() => setOpen(false)}>
<ListItemText primary="Admin login" />
</ListItemButton>
</List>
</Box>
</Drawer>
</AppBar>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import type { ReactNode } from 'react';
// A consistent full-width section with an id (for anchor nav), a centred
// heading + eyebrow, and a content slot. Alternating background handled by the
// `tinted` prop so sections visually separate in both themes.
export function Section({
id,
eyebrow,
title,
subtitle,
tinted,
children,
}: {
id?: string;
eyebrow?: string;
title?: string;
subtitle?: string;
tinted?: boolean;
children: ReactNode;
}) {
return (
<Box
id={id}
component="section"
sx={{
py: { xs: 7, md: 11 },
scrollMarginTop: '72px',
bgcolor: tinted ? 'action.hover' : 'background.default',
}}
>
<Container maxWidth="lg">
{(eyebrow || title || subtitle) && (
<Box sx={{ textAlign: 'center', mb: { xs: 4, md: 6 }, maxWidth: 720, mx: 'auto' }}>
{eyebrow && (
<Typography
sx={{
color: 'secondary.main',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
fontSize: '0.8rem',
mb: 1.5,
}}
>
{eyebrow}
</Typography>
)}
{title && (
<Typography variant="h2" sx={{ mb: subtitle ? 2 : 0 }}>
{title}
</Typography>
)}
{subtitle && (
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
{subtitle}
</Typography>
)}
</Box>
)}
{children}
</Container>
</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded';
import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { useColorMode } from '../theme/ColorMode';
export function ThemeToggle() {
const { mode, toggle } = useColorMode();
return (
<Tooltip title={mode === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}>
<IconButton
onClick={toggle}
aria-label="Toggle light and dark theme"
sx={{
border: 1,
borderColor: 'divider',
color: 'text.primary',
bgcolor: 'background.paper',
}}
>
{mode === 'light' ? <DarkModeRoundedIcon fontSize="small" /> : <LightModeRoundedIcon fontSize="small" />}
</IconButton>
</Tooltip>
);
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { ColorModeProvider } from './theme/ColorMode';
// The one entry point. Never add src/index.tsx alongside it.
if (import.meta.env.DEV) void import('../vc/runtime');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ColorModeProvider>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</ColorModeProvider>
</React.StrictMode>,
);
+350
View File
@@ -0,0 +1,350 @@
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import EmojiEventsRoundedIcon from '@mui/icons-material/EmojiEventsRounded';
import FormatQuoteRoundedIcon from '@mui/icons-material/FormatQuoteRounded';
import GroupsRoundedIcon from '@mui/icons-material/GroupsRounded';
import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import WorkspacePremiumRoundedIcon from '@mui/icons-material/WorkspacePremiumRounded';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Container from '@mui/material/Container';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { ContactSection } from '../components/ContactSection';
import { Footer } from '../components/Footer';
import { Navbar } from '../components/Navbar';
import { Section } from '../components/Section';
import {
getCourses,
getFees,
getProfile,
getSettings,
getSubjects,
getTestimonials,
} from '../services/content';
import { SEED, type Course, type FeePlan, type Profile, type SiteSettings, type Subject, type Testimonial } from '../services/mockData';
import { brandGradient } from '../theme';
import { useColorMode } from '../theme/ColorMode';
const lines = (s: string) => (s ?? '').split('\n').map((x) => x.trim()).filter(Boolean);
export function HomePage() {
const { mode } = useColorMode();
const [profile, setProfile] = useState<Profile>(SEED.profile);
const [subjects, setSubjects] = useState<Subject[]>(SEED.subjects);
const [courses, setCourses] = useState<Course[]>(SEED.courses);
const [fees, setFees] = useState<FeePlan[]>(SEED.fees);
const [testimonials, setTestimonials] = useState<Testimonial[]>(SEED.testimonials);
const [settings, setSettings] = useState<SiteSettings>(SEED.settings);
useEffect(() => {
let on = true;
getProfile().then((d) => on && setProfile(d));
getSubjects().then((d) => on && setSubjects(d));
getCourses().then((d) => on && setCourses(d));
getFees().then((d) => on && setFees(d));
getTestimonials().then((d) => on && setTestimonials(d));
getSettings().then((d) => on && setSettings(d));
return () => {
on = false;
};
}, []);
const scrollTo = (href: string) => {
const el = document.querySelector(href);
if (el) el.scrollIntoView({ behavior: 'smooth' });
};
const stats = [
{ icon: <WorkspacePremiumRoundedIcon />, value: `${profile.experience_years}+`, label: 'Years teaching' },
{ icon: <GroupsRoundedIcon />, value: `${profile.students_taught}+`, label: 'Students taught' },
{ icon: <EmojiEventsRoundedIcon />, value: '95%', label: 'Improved grades' },
];
const initials = (profile.name ?? '')
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('');
return (
<Box sx={{ bgcolor: 'background.default', minHeight: '100vh' }}>
<Navbar brandName={profile.name} />
{/* ---- Hero ---- */}
<Box sx={{ position: 'relative', overflow: 'hidden', bgcolor: 'background.default' }}>
<Box
sx={{
position: 'absolute',
top: -160,
right: -120,
width: 460,
height: 460,
borderRadius: '50%',
background: brandGradient(mode),
opacity: mode === 'light' ? 0.14 : 0.22,
filter: 'blur(20px)',
}}
/>
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 12 } }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1.1fr 0.9fr' },
gap: { xs: 5, md: 6 },
alignItems: 'center',
}}
>
<Box>
<Chip
label="Trusted personal tuition"
sx={{ mb: 3, bgcolor: 'action.hover', color: 'secondary.main', fontWeight: 700 }}
/>
<Typography variant="h1" sx={{ mb: 2 }}>
Hi, I'm{' '}
<Box component="span" sx={{ background: brandGradient(mode), WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
{profile.name}
</Box>
</Typography>
<Typography variant="h5" sx={{ color: 'text.secondary', fontWeight: 500, mb: 3 }}>
{profile.tagline}
</Typography>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
<Button variant="contained" size="large" onClick={() => scrollTo('#contact')}>
{profile.hero_cta}
</Button>
<Button variant="outlined" size="large" onClick={() => scrollTo('#subjects')}>
Explore subjects
</Button>
</Stack>
<Box sx={{ display: 'flex', gap: { xs: 3, sm: 5 }, mt: 5, flexWrap: 'wrap' }}>
{stats.map((s) => (
<Box key={s.label} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ color: 'primary.main', display: 'grid', placeItems: 'center' }}>{s.icon}</Box>
<Box>
<Typography variant="h4" sx={{ lineHeight: 1 }}>
{s.value}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{s.label}
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
<Box sx={{ display: 'grid', placeItems: 'center' }}>
<Box
sx={{
position: 'relative',
width: { xs: 240, sm: 300 },
height: { xs: 240, sm: 300 },
borderRadius: '50%',
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
boxShadow: 6,
}}
>
<Avatar
src={profile.photo_url || undefined}
sx={{ width: '92%', height: '92%', fontSize: '4rem', bgcolor: 'background.paper', color: 'primary.main', fontWeight: 800 }}
>
{initials}
</Avatar>
<Chip
icon={<StarRoundedIcon />}
label="Top rated"
sx={{ position: 'absolute', bottom: 8, bgcolor: 'background.paper', color: 'warning.main', fontWeight: 700, boxShadow: 3 }}
/>
</Box>
</Box>
</Box>
</Container>
</Box>
{/* ---- About ---- */}
<Section id="about" tinted eyebrow="About me" title="A teacher who truly cares">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 5, alignItems: 'center' }}>
<Box>
<Typography variant="body1" sx={{ color: 'text.secondary', mb: 3 }}>
{profile.bio}
</Typography>
<Chip icon={<SchoolRoundedIcon />} label={profile.qualifications} sx={{ mb: 3, py: 2.4, height: 'auto', fontWeight: 600 }} />
<Stack spacing={1.5}>
{lines(profile.achievements).map((a) => (
<Box key={a} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
<CheckCircleRoundedIcon sx={{ color: 'success.main', fontSize: 22, mt: '2px' }} />
<Typography variant="body1">{a}</Typography>
</Box>
))}
</Stack>
</Box>
<Card sx={{ p: 1, bgcolor: 'background.paper' }}>
<CardContent>
<Typography variant="h4" sx={{ mb: 2 }}>
Why students love learning here
</Typography>
<Stack spacing={2}>
{[
'Concepts explained until they truly click',
'Regular tests and honest, kind feedback',
'Flexible online & offline classes',
'Personal attention for every learner',
].map((p) => (
<Box key={p} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CheckCircleRoundedIcon sx={{ color: 'primary.main' }} />
<Typography variant="body1">{p}</Typography>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Box>
</Section>
{/* ---- Subjects ---- */}
<Section id="subjects" eyebrow="What I teach" title="Subjects & levels" subtitle="Strong fundamentals across the subjects that matter most.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 3 }}>
{subjects.map((s) => (
<Card key={s.id} sx={{ '&:hover': { transform: 'translateY(-6px)', boxShadow: 4, borderColor: 'primary.main' } }}>
<CardContent>
<Typography sx={{ fontSize: '2.4rem', mb: 1 }}>{s.icon}</Typography>
<Typography variant="h5" sx={{ mb: 0.5 }}>
{s.name}
</Typography>
<Chip label={s.level} size="small" sx={{ mb: 1.5, bgcolor: 'action.hover' }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{s.description}
</Typography>
</CardContent>
</Card>
))}
</Box>
</Section>
{/* ---- Courses ---- */}
<Section id="courses" tinted eyebrow="Programmes" title="Courses designed to work" subtitle="Structured programmes for every stage and goal.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3 }}>
{courses.map((c) => (
<Card key={c.id} sx={{ bgcolor: 'background.paper', display: 'flex', flexDirection: 'column', '&:hover': { transform: 'translateY(-6px)', boxShadow: 4 } }}>
<CardContent sx={{ flexGrow: 1 }}>
<Chip label={c.subject} size="small" color="secondary" sx={{ mb: 1.5 }} />
<Typography variant="h5" sx={{ mb: 1 }}>
{c.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{c.description}
</Typography>
<Stack spacing={1}>
{lines(c.highlights).map((h) => (
<Box key={h} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleRoundedIcon sx={{ color: 'success.main', fontSize: 18 }} />
<Typography variant="body2">{h}</Typography>
</Box>
))}
</Stack>
</CardContent>
<Box sx={{ px: 2, pb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Chip label={c.duration} size="small" variant="outlined" />
<Chip label={c.mode} size="small" variant="outlined" />
</Box>
</Card>
))}
</Box>
</Section>
{/* ---- Fees ---- */}
<Section id="fees" eyebrow="Simple pricing" title="Fees & packages" subtitle="Clear, honest pricing — no hidden charges.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3, alignItems: 'stretch' }}>
{fees.map((f) => (
<Card
key={f.id}
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderColor: f.highlighted ? 'primary.main' : 'divider',
borderWidth: f.highlighted ? 2 : 1,
transform: { md: f.highlighted ? 'scale(1.04)' : 'none' },
'&:hover': { boxShadow: 4 },
}}
>
{f.highlighted && (
<Chip label="Most popular" color="primary" size="small" sx={{ position: 'absolute', top: 16, right: 16 }} />
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
{f.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 2 }}>
<Typography variant="h2" sx={{ fontWeight: 800 }}>
{f.price}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{f.period}
</Typography>
</Box>
<Stack spacing={1.2}>
{lines(f.features).map((feat) => (
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleRoundedIcon sx={{ color: 'primary.main', fontSize: 18 }} />
<Typography variant="body2">{feat}</Typography>
</Box>
))}
</Stack>
</CardContent>
<Box sx={{ p: 2 }}>
<Button fullWidth variant={f.highlighted ? 'contained' : 'outlined'} onClick={() => scrollTo('#contact')}>
Choose {f.name}
</Button>
</Box>
</Card>
))}
</Box>
</Section>
{/* ---- Testimonials ---- */}
<Section id="testimonials" tinted eyebrow="Kind words" title="What students & parents say">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 3 }}>
{testimonials.map((t) => (
<Card key={t.id} sx={{ bgcolor: 'background.paper' }}>
<CardContent>
<FormatQuoteRoundedIcon sx={{ color: 'secondary.main', fontSize: 40, opacity: 0.6 }} />
<Typography variant="body1" sx={{ mb: 2, fontStyle: 'italic' }}>
{t.quote}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar sx={{ bgcolor: 'primary.main' }}>{t.author_name[0]}</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t.author_name}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.relation}
</Typography>
</Box>
<Rating value={t.rating} readOnly size="small" />
</Box>
</CardContent>
</Card>
))}
</Box>
</Section>
{/* ---- Contact ---- */}
<ContactSection settings={settings} subjects={subjects} />
<Footer profile={profile} settings={settings} />
</Box>
);
}
+373
View File
@@ -0,0 +1,373 @@
import CalendarMonthRoundedIcon from '@mui/icons-material/CalendarMonthRounded';
import CollectionsBookmarkRoundedIcon from '@mui/icons-material/CollectionsBookmarkRounded';
import GroupsRoundedIcon from '@mui/icons-material/GroupsRounded';
import LockResetRoundedIcon from '@mui/icons-material/LockResetRounded';
import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded';
import MailRoundedIcon from '@mui/icons-material/MailRounded';
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import PaymentsRoundedIcon from '@mui/icons-material/PaymentsRounded';
import PersonRoundedIcon from '@mui/icons-material/PersonRounded';
import RateReviewRoundedIcon from '@mui/icons-material/RateReviewRounded';
import ReviewsRoundedIcon from '@mui/icons-material/ReviewsRounded';
import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Container from '@mui/material/Container';
import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { ThemeToggle } from '../../components/ThemeToggle';
import {
createCourse,
createFee,
createSubject,
createTestimonial,
deleteCourse,
deleteFee,
deleteSubject,
deleteTestimonial,
getCourses,
getFees,
getProfile,
getSettings,
getSubjects,
getTestimonials,
updateCourse,
updateFee,
updateProfile,
updateSettings,
updateSubject,
updateTestimonial,
} from '../../services/content';
import {
SEED,
type Course,
type FeePlan,
type Profile,
type SiteSettings,
type Subject,
type Testimonial,
} from '../../services/mockData';
import { useAuth } from '../../store/auth';
import { AttendancePanel } from './AttendancePanel';
import { ChangePasswordPanel } from './ChangePasswordPanel';
import { CrudManager } from './CrudManager';
import { EnquiriesPanel } from './EnquiriesPanel';
import { ReviewsPanel } from './ReviewsPanel';
import { SchedulePanel } from './SchedulePanel';
import { SingletonEditor } from './SingletonEditor';
import { StudentsPanel } from './StudentsPanel';
const DRAWER = 260;
type TabKey =
| 'students'
| 'attendance'
| 'schedule'
| 'profile'
| 'subjects'
| 'courses'
| 'fees'
| 'testimonials'
| 'settings'
| 'enquiries'
| 'reviews'
| 'password';
const NAV: { key: TabKey; label: string; icon: JSX.Element }[] = [
{ key: 'students', label: 'Students', icon: <GroupsRoundedIcon /> },
{ key: 'attendance', label: 'Attendance', icon: <CalendarMonthRoundedIcon /> },
{ key: 'schedule', label: 'Schedule', icon: <ScheduleRoundedIcon /> },
{ key: 'profile', label: 'Profile & About', icon: <PersonRoundedIcon /> },
{ key: 'subjects', label: 'Subjects', icon: <MenuBookRoundedIcon /> },
{ key: 'courses', label: 'Courses', icon: <CollectionsBookmarkRoundedIcon /> },
{ key: 'fees', label: 'Fees', icon: <PaymentsRoundedIcon /> },
{ key: 'testimonials', label: 'Testimonials', icon: <ReviewsRoundedIcon /> },
{ key: 'settings', label: 'Contact settings', icon: <SettingsRoundedIcon /> },
{ key: 'enquiries', label: 'Enquiries', icon: <MailRoundedIcon /> },
{ key: 'reviews', label: 'Reviews', icon: <RateReviewRoundedIcon /> },
{ key: 'password', label: 'Change password', icon: <LockResetRoundedIcon /> },
];
export function AdminDashboard() {
const navigate = useNavigate();
const { username, logout } = useAuth();
const [tab, setTab] = useState<TabKey>('students');
const [profile, setProfile] = useState<Profile>(SEED.profile);
const [settings, setSettings] = useState<SiteSettings>(SEED.settings);
const [subjects, setSubjects] = useState<Subject[]>(SEED.subjects);
const [courses, setCourses] = useState<Course[]>(SEED.courses);
const [fees, setFees] = useState<FeePlan[]>(SEED.fees);
const [testimonials, setTestimonials] = useState<Testimonial[]>(SEED.testimonials);
const reload = () => {
getProfile().then(setProfile);
getSettings().then(setSettings);
getSubjects().then(setSubjects);
getCourses().then(setCourses);
getFees().then(setFees);
getTestimonials().then(setTestimonials);
};
useEffect(reload, []);
const doLogout = () => {
logout();
navigate('/admin', { replace: true });
};
const drawer = (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Toolbar>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
Admin panel
</Typography>
</Toolbar>
<List sx={{ px: 1, flexGrow: 1 }}>
{NAV.map((n) => (
<ListItemButton
key={n.key}
selected={tab === n.key}
onClick={() => setTab(n.key)}
sx={{ borderRadius: 2, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40, color: tab === n.key ? 'primary.main' : 'text.secondary' }}>
{n.icon}
</ListItemIcon>
<ListItemText primary={n.label} />
</ListItemButton>
))}
</List>
<Box sx={{ p: 2 }}>
<Button fullWidth variant="outlined" startIcon={<LogoutRoundedIcon />} onClick={doLogout}>
Sign out
</Button>
</Box>
</Box>
);
return (
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
<AppBar
position="fixed"
sx={{ zIndex: (t) => t.zIndex.drawer + 1, bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}
>
<Toolbar>
<Typography variant="h6" sx={{ fontWeight: 800, color: 'text.primary', flexGrow: 1 }}>
SwatiYadav · Dashboard
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mr: 2, display: { xs: 'none', sm: 'block' } }}>
{username}
</Typography>
<Button
component={RouterLink}
to="/"
target="_blank"
startIcon={<OpenInNewRoundedIcon />}
sx={{ mr: 1, color: 'text.secondary', display: { xs: 'none', sm: 'inline-flex' } }}
>
View site
</Button>
<ThemeToggle />
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: DRAWER,
flexShrink: 0,
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': { width: DRAWER, boxSizing: 'border-box', bgcolor: 'background.paper' },
}}
>
{drawer}
</Drawer>
<Box component="main" sx={{ flexGrow: 1, width: { md: `calc(100% - ${DRAWER}px)` } }}>
<Toolbar />
{/* Mobile tab bar */}
<Box
sx={{
display: { xs: 'flex', md: 'none' },
gap: 1,
overflowX: 'auto',
p: 1.5,
borderBottom: 1,
borderColor: 'divider',
}}
>
{NAV.map((n) => (
<Button
key={n.key}
size="small"
variant={tab === n.key ? 'contained' : 'outlined'}
onClick={() => setTab(n.key)}
sx={{ flexShrink: 0 }}
>
{n.label}
</Button>
))}
<Button size="small" color="error" variant="outlined" onClick={doLogout} sx={{ flexShrink: 0 }}>
Sign out
</Button>
</Box>
<Container maxWidth="lg" sx={{ py: 4 }}>
{tab === 'students' && <StudentsPanel />}
{tab === 'attendance' && <AttendancePanel />}
{tab === 'schedule' && <SchedulePanel />}
{tab === 'profile' && (
<SingletonEditor
title="Profile & About"
description="Your name, tagline, bio and highlights shown across the site."
value={profile}
onSave={async (d) => {
const saved = await updateProfile(d);
setProfile(saved);
}}
fields={[
{ key: 'name', label: 'Full name' },
{ key: 'tagline', label: 'Tagline' },
{ key: 'bio', label: 'Bio', type: 'multiline' },
{ key: 'photo_url', label: 'Photo URL', helper: 'Optional link to your photo' },
{ key: 'qualifications', label: 'Qualifications' },
{ key: 'experience_years', label: 'Years of experience', type: 'number' },
{ key: 'students_taught', label: 'Students taught', type: 'number' },
{ key: 'achievements', label: 'Achievements', type: 'multiline', helper: 'One per line' },
{ key: 'hero_cta', label: 'Hero button text' },
]}
/>
)}
{tab === 'subjects' && (
<CrudManager<Subject>
title="Subjects"
description="The subjects you teach."
rows={subjects}
renderPrimary={(r) => `${r.icon} ${r.name}`}
renderSecondary={(r) => `${r.level} — ${r.description}`}
empty={{ name: '', level: '', description: '', mode: 'Online & Offline', icon: '📘', sort_order: subjects.length + 1 }}
fields={[
{ key: 'name', label: 'Subject name' },
{ key: 'level', label: 'Level (e.g. Class 6–12)' },
{ key: 'icon', label: 'Emoji icon' },
{ key: 'mode', label: 'Mode' },
{ key: 'description', label: 'Description', type: 'multiline' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createSubject(d); getSubjects().then(setSubjects); }}
onUpdate={async (id, d) => { await updateSubject(id, d); getSubjects().then(setSubjects); }}
onDelete={async (id) => { await deleteSubject(id); getSubjects().then(setSubjects); }}
/>
)}
{tab === 'courses' && (
<CrudManager<Course>
title="Courses"
description="Your programmes and packages."
rows={courses}
renderPrimary={(r) => r.title}
renderSecondary={(r) => `${r.subject} · ${r.duration}`}
empty={{ title: '', subject: '', description: '', duration: '', mode: 'Online & Offline', highlights: '', sort_order: courses.length + 1 }}
fields={[
{ key: 'title', label: 'Course title' },
{ key: 'subject', label: 'Subject(s)' },
{ key: 'duration', label: 'Duration' },
{ key: 'mode', label: 'Mode' },
{ key: 'description', label: 'Description', type: 'multiline' },
{ key: 'highlights', label: 'Highlights', type: 'multiline', helper: 'One per line' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createCourse(d); getCourses().then(setCourses); }}
onUpdate={async (id, d) => { await updateCourse(id, d); getCourses().then(setCourses); }}
onDelete={async (id) => { await deleteCourse(id); getCourses().then(setCourses); }}
/>
)}
{tab === 'fees' && (
<CrudManager<FeePlan>
title="Fees"
description="Your pricing packages."
rows={fees}
renderPrimary={(r) => `${r.name} — ${r.price}`}
renderSecondary={(r) => r.period}
empty={{ name: '', price: '', period: 'per month', features: '', highlighted: false, sort_order: fees.length + 1 }}
fields={[
{ key: 'name', label: 'Package name' },
{ key: 'price', label: 'Price (e.g. ₹2,500)' },
{ key: 'period', label: 'Period (e.g. per month)' },
{ key: 'features', label: 'Features', type: 'multiline', helper: 'One per line' },
{ key: 'highlighted', label: 'Highlight as most popular', type: 'boolean' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createFee(d); getFees().then(setFees); }}
onUpdate={async (id, d) => { await updateFee(id, d); getFees().then(setFees); }}
onDelete={async (id) => { await deleteFee(id); getFees().then(setFees); }}
/>
)}
{tab === 'testimonials' && (
<CrudManager<Testimonial>
title="Testimonials"
description="Reviews from students and parents."
rows={testimonials}
renderPrimary={(r) => `${r.author_name} (${r.relation})`}
renderSecondary={(r) => r.quote}
empty={{ author_name: '', relation: 'Student', quote: '', rating: 5, sort_order: testimonials.length + 1 }}
fields={[
{ key: 'author_name', label: 'Author name' },
{ key: 'relation', label: 'Relation (Student / Parent)' },
{ key: 'quote', label: 'Quote', type: 'multiline' },
{ key: 'rating', label: 'Rating (1–5)', type: 'number' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createTestimonial(d); getTestimonials().then(setTestimonials); }}
onUpdate={async (id, d) => { await updateTestimonial(id, d); getTestimonials().then(setTestimonials); }}
onDelete={async (id) => { await deleteTestimonial(id); getTestimonials().then(setTestimonials); }}
/>
)}
{tab === 'settings' && (
<SingletonEditor
title="Contact settings"
description="Contact details, hours, social links and footer text."
value={settings}
onSave={async (d) => {
const saved = await updateSettings(d);
setSettings(saved);
}}
fields={[
{ key: 'phone', label: 'Phone' },
{ key: 'email', label: 'Email' },
{ key: 'address', label: 'Address', type: 'multiline' },
{ key: 'hours', label: 'Working hours' },
{ key: 'whatsapp', label: 'WhatsApp link' },
{ key: 'instagram', label: 'Instagram link' },
{ key: 'youtube', label: 'YouTube link' },
{ key: 'facebook', label: 'Facebook link' },
{ key: 'footer_text', label: 'Footer text', type: 'multiline' },
]}
/>
)}
{tab === 'enquiries' && <EnquiriesPanel />}
{tab === 'reviews' && <ReviewsPanel />}
{tab === 'password' && <ChangePasswordPanel />}
</Container>
</Box>
</Box>
);
}
+100
View File
@@ -0,0 +1,100 @@
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import LockRoundedIcon from '@mui/icons-material/LockRounded';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { ThemeToggle } from '../../components/ThemeToggle';
import { brandGradient } from '../../theme';
import { useColorMode } from '../../theme/ColorMode';
import { useAuth } from '../../store/auth';
export function AdminLogin() {
const { mode } = useColorMode();
const navigate = useNavigate();
const { login, loading, error, token } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
useEffect(() => {
if (token) navigate('/admin/dashboard', { replace: true });
}, [token, navigate]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const ok = await login(username.trim(), password);
if (ok) navigate('/admin/dashboard', { replace: true });
};
return (
<Box
sx={{
minHeight: '100vh',
bgcolor: 'background.default',
display: 'grid',
placeItems: 'center',
p: 2,
position: 'relative',
}}
>
<Box sx={{ position: 'absolute', top: 16, right: 16 }}>
<ThemeToggle />
</Box>
<Button component={RouterLink} to="/" startIcon={<ArrowBackRoundedIcon />} sx={{ position: 'absolute', top: 16, left: 16, color: 'text.secondary' }}>
Back to site
</Button>
<Card sx={{ width: '100%', maxWidth: 420 }}>
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: 3,
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
color: '#fff',
mx: 'auto',
mb: 2,
}}
>
<LockRoundedIcon />
</Box>
<Typography variant="h4">Teacher login</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
Sign in to update your website
</Typography>
</Box>
<Box component="form" onSubmit={submit}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={2.5}>
<TextField label="Username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus fullWidth required />
<TextField label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} fullWidth required />
<Button type="submit" variant="contained" size="large" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</Stack>
</Box>
<Alert severity="info" sx={{ mt: 3 }}>
Default login: <strong>DEVTeacher</strong> / <strong>DEVTeacher</strong>. Please change the password after signing in.
</Alert>
</CardContent>
</Card>
</Box>
);
}
@@ -0,0 +1,500 @@
import BeachAccessRoundedIcon from '@mui/icons-material/BeachAccessRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useMemo, useState } from 'react';
import {
clearAttendance,
deleteDayMark,
getAttendance,
listStudents,
setAttendance,
setDayMark,
} from '../../services/content';
import {
SEED,
type Attendance,
type AttendanceStatus,
type DayMark,
type DayMarkKind,
type Student,
} from '../../services/mockData';
const pad = (n: number) => String(n).padStart(2, '0');
const monthKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
const WD = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Cell cycle: blank -> present -> absent -> off -> blank
const NEXT: Record<string, AttendanceStatus | ''> = {
'': 'present',
present: 'absent',
absent: 'off',
off: '',
};
const CELL: Record<AttendanceStatus, { label: string; bg: string; color: string }> = {
present: { label: 'P', bg: 'success.main', color: 'success.contrastText' },
absent: { label: 'A', bg: 'error.main', color: 'error.contrastText' },
off: { label: 'O', bg: 'warning.main', color: 'warning.contrastText' },
};
const STATUS_WORD: Record<AttendanceStatus, string> = {
present: 'Present',
absent: 'Absent',
off: 'Student off',
};
const key = (studentId: number, day: number) => `${studentId}:${day}`;
type NoteDialog = {
student: Student;
day: number;
status: AttendanceStatus;
value: string;
};
export function AttendancePanel() {
const [month, setMonth] = useState<string>(monthKey(new Date()));
const [students, setStudents] = useState<Student[]>([]);
const [att, setAtt] = useState<Record<string, AttendanceStatus>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const [marks, setMarks] = useState<Record<number, DayMark>>({});
const [menu, setMenu] = useState<{ anchor: HTMLElement; day: number } | null>(null);
const [noteDialog, setNoteDialog] = useState<NoteDialog | null>(null);
const [year, mon] = month.split('-').map(Number);
const daysInMonth = new Date(year, mon, 0).getDate();
const days = useMemo(() => Array.from({ length: daysInMonth }, (_, i) => i + 1), [daysInMonth]);
useEffect(() => {
listStudents()
.then((d) => setStudents(d.filter((s) => s.is_active)))
.catch(() => setStudents(SEED.students.filter((s) => s.is_active)));
}, []);
const loadMonth = () => {
getAttendance(month)
.then((d) => applyMonthData(d.attendance, d.day_marks))
.catch(() =>
applyMonthData(
SEED.attendance.filter((a) => a.date.startsWith(month)),
SEED.dayMarks.filter((m) => m.date.startsWith(month)),
),
);
};
const applyMonthData = (attendance: Attendance[], dayMarks: DayMark[]) => {
const a: Record<string, AttendanceStatus> = {};
const n: Record<string, string> = {};
attendance.forEach((row) => {
const day = Number(row.date.slice(8, 10));
a[key(row.student_id, day)] = row.status;
if (row.note) n[key(row.student_id, day)] = row.note;
});
setAtt(a);
setNotes(n);
const m: Record<number, DayMark> = {};
dayMarks.forEach((row) => {
m[Number(row.date.slice(8, 10))] = row;
});
setMarks(m);
};
useEffect(loadMonth, [month]);
const dateStr = (day: number) => `${year}-${pad(mon)}-${pad(day)}`;
// Persist a status (+note) for one student/day.
const saveCell = async (
student: Student,
day: number,
next: AttendanceStatus | '',
note: string,
) => {
setAtt((prev) => {
const copy = { ...prev };
if (next === '') delete copy[key(student.id, day)];
else copy[key(student.id, day)] = next;
return copy;
});
setNotes((prev) => {
const copy = { ...prev };
if (next === '' || !note) delete copy[key(student.id, day)];
else copy[key(student.id, day)] = note;
return copy;
});
try {
if (next === '') await clearAttendance({ student_id: student.id, date: dateStr(day) });
else
await setAttendance({
student_id: student.id,
date: dateStr(day),
status: next,
note,
});
} catch {
/* optimistic — preview / offline */
}
};
// Left-click cycles the cell. Landing on Absent / Off opens the note dialog.
const cycleCell = async (student: Student, day: number) => {
const cur = att[key(student.id, day)] ?? '';
const next = NEXT[cur];
const existingNote = notes[key(student.id, day)] ?? '';
await saveCell(student, day, next, next === cur ? existingNote : '');
if (next === 'absent' || next === 'off') {
setNoteDialog({ student, day, status: next, value: '' });
}
};
// Right-click a marked cell to view / edit its reason without changing status.
const openNote = (student: Student, day: number, e: React.MouseEvent) => {
e.preventDefault();
const status = att[key(student.id, day)];
if (status !== 'absent' && status !== 'off') return;
setNoteDialog({ student, day, status, value: notes[key(student.id, day)] ?? '' });
};
const saveNote = async () => {
if (!noteDialog) return;
const { student, day, status, value } = noteDialog;
setNoteDialog(null);
await saveCell(student, day, status, value.trim());
};
const applyDayMark = async (day: number, kind: DayMarkKind | null) => {
setMenu(null);
if (kind === null) {
setMarks((prev) => {
const copy = { ...prev };
delete copy[day];
return copy;
});
try {
await deleteDayMark(dateStr(day));
} catch {
/* optimistic */
}
return;
}
setMarks((prev) => ({ ...prev, [day]: { id: prev[day]?.id ?? 0, date: dateStr(day), kind, note: '' } }));
try {
await setDayMark({ date: dateStr(day), kind });
} catch {
/* optimistic */
}
};
const weekdayOf = (day: number) => new Date(year, mon - 1, day).getDay();
// Per-student summary for the month
const summary = students.map((s) => {
let present = 0;
let absent = 0;
let off = 0;
days.forEach((d) => {
const st = att[key(s.id, d)];
if (st === 'present') present += 1;
else if (st === 'absent') absent += 1;
else if (st === 'off') off += 1;
});
return { student: s, present, absent, off };
});
// Flat list of all absent/off entries that carry a reason, for the log below.
type Reason = { student: Student; day: number; status: AttendanceStatus; note: string };
const reasons: Reason[] = [];
students.forEach((s) => {
days.forEach((d) => {
const st = att[key(s.id, d)];
const note = notes[key(s.id, d)];
if ((st === 'absent' || st === 'off') && note) {
reasons.push({ student: s, day: d, status: st, note });
}
});
});
reasons.sort((a, b) => a.day - b.day);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="h5">Attendance</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Tap a cell to cycle Present → Absent → Off → blank. When you mark Absent or Off you can add a reason.
Right-click a marked cell to view or edit its reason. Click a day header to mark a holiday.
</Typography>
</Box>
<TextField
type="month"
size="small"
label="Month"
value={month}
onChange={(e) => setMonth(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Box>
{/* Legend */}
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Chip size="small" label="P = Present" sx={{ bgcolor: 'success.main', color: 'success.contrastText' }} />
<Chip size="small" label="A = Absent" sx={{ bgcolor: 'error.main', color: 'error.contrastText' }} />
<Chip size="small" label="O = Student off" sx={{ bgcolor: 'warning.main', color: 'warning.contrastText' }} />
<Chip size="small" icon={<BeachAccessRoundedIcon />} label="Holiday / Class off (whole day)" variant="outlined" />
<Chip size="small" label="• = has a reason" variant="outlined" />
</Stack>
{students.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Add active students first — they will appear here.
</Typography>
) : (
<Card sx={{ mb: 3 }}>
<TableContainer sx={{ maxHeight: 520 }}>
<Table size="small" stickyHeader sx={{ '& td, & th': { px: 0.5, textAlign: 'center' } }}>
<TableHead>
<TableRow>
<TableCell sx={{ position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper', textAlign: 'left !important', minWidth: 140 }}>
Student
</TableCell>
{days.map((d) => {
const mark = marks[d];
const wd = weekdayOf(d);
return (
<TableCell
key={d}
onClick={(e) => setMenu({ anchor: e.currentTarget, day: d })}
sx={{
cursor: 'pointer',
minWidth: 30,
bgcolor: mark ? 'action.selected' : undefined,
color: wd === 0 ? 'error.main' : 'text.secondary',
}}
title={mark ? (mark.kind === 'holiday' ? 'Holiday' : 'Class off') : 'Click to mark a holiday'}
>
<Typography variant="caption" sx={{ display: 'block', lineHeight: 1 }}>
{WD[wd]}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{mark ? '🏖' : d}
</Typography>
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{students.map((s) => (
<TableRow key={s.id}>
<TableCell sx={{ position: 'sticky', left: 0, zIndex: 1, bgcolor: 'background.paper', textAlign: 'left !important' }}>
<Typography variant="body2" sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
{s.name}
</Typography>
</TableCell>
{days.map((d) => {
const st = att[key(s.id, d)];
const note = notes[key(s.id, d)];
const dayMarked = Boolean(marks[d]);
const style = st ? CELL[st] : null;
const cell = (
<TableCell
key={d}
onClick={() => cycleCell(s, d)}
onContextMenu={(e) => openNote(s, d, e)}
sx={{
position: 'relative',
cursor: 'pointer',
bgcolor: dayMarked && !st ? 'action.disabledBackground' : style?.bg,
color: style?.color,
fontWeight: 700,
userSelect: 'none',
'&:hover': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
}}
>
{style?.label ?? (dayMarked ? '·' : '')}
{note && (
<Box
component="span"
sx={{
position: 'absolute',
top: 2,
right: 3,
width: 5,
height: 5,
borderRadius: '50%',
bgcolor: style?.color ?? 'text.primary',
opacity: 0.9,
}}
/>
)}
</TableCell>
);
return note ? (
<Tooltip key={d} title={note} arrow>
{cell}
</Tooltip>
) : (
cell
);
})}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
<Menu anchorEl={menu?.anchor} open={Boolean(menu)} onClose={() => setMenu(null)}>
<MenuItem onClick={() => menu && applyDayMark(menu.day, 'holiday')}>Mark as Holiday</MenuItem>
<MenuItem onClick={() => menu && applyDayMark(menu.day, 'class_off')}>Mark as Class off</MenuItem>
<MenuItem onClick={() => menu && applyDayMark(menu.day, null)}>Clear day mark</MenuItem>
</Menu>
{/* Reason dialog for absent / off */}
<Dialog open={Boolean(noteDialog)} onClose={() => setNoteDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>
{noteDialog && (
<>
{STATUS_WORD[noteDialog.status]} — {noteDialog.student.name}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateStr(noteDialog.day)}
</Typography>
</>
)}
</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
multiline
minRows={2}
label="Reason / notes"
placeholder={
noteDialog?.status === 'absent'
? 'e.g. Was unwell, informed by parent'
: 'e.g. Family function, exam at school'
}
value={noteDialog?.value ?? ''}
onChange={(e) => setNoteDialog((prev) => (prev ? { ...prev, value: e.target.value } : prev))}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) saveNote();
}}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setNoteDialog(null)}>Cancel</Button>
<Button variant="contained" onClick={saveNote}>
Save reason
</Button>
</DialogActions>
</Dialog>
{/* Per-student month summary */}
{students.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>
This month at a glance
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Student</TableCell>
<TableCell align="center">Present</TableCell>
<TableCell align="center">Absent</TableCell>
<TableCell align="center">Off</TableCell>
</TableRow>
</TableHead>
<TableBody>
{summary.map((r) => (
<TableRow key={r.student.id}>
<TableCell>{r.student.name}</TableCell>
<TableCell align="center">
<Chip size="small" label={r.present} color="success" variant="outlined" />
</TableCell>
<TableCell align="center">
<Chip size="small" label={r.absent} color="error" variant="outlined" />
</TableCell>
<TableCell align="center">
<Chip size="small" label={r.off} color="warning" variant="outlined" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
)}
{/* Reasons log — why students were absent / off this month */}
{students.length > 0 && (
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>
Absence &amp; off reasons this month
</Typography>
{reasons.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No reasons recorded yet. When you mark a student Absent or Off, add a note so you can look back later.
</Typography>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Student</TableCell>
<TableCell>Status</TableCell>
<TableCell>Reason</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reasons.map((r) => (
<TableRow key={`${r.student.id}:${r.day}`}>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{dateStr(r.day)}</TableCell>
<TableCell>{r.student.name}</TableCell>
<TableCell>
<Chip
size="small"
label={STATUS_WORD[r.status]}
color={r.status === 'absent' ? 'error' : 'warning'}
variant="outlined"
/>
</TableCell>
<TableCell>{r.note}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,68 @@
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { useAuth } from '../../store/auth';
export function ChangePasswordPanel() {
const changePassword = useAuth((s) => s.changePassword);
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setMsg(null);
if (next.length < 6) {
setMsg({ ok: false, text: 'New password must be at least 6 characters.' });
return;
}
if (next !== confirm) {
setMsg({ ok: false, text: 'New passwords do not match.' });
return;
}
setBusy(true);
const err = await changePassword(current, next);
setBusy(false);
if (err) {
setMsg({ ok: false, text: err });
} else {
setMsg({ ok: true, text: 'Password changed successfully.' });
setCurrent('');
setNext('');
setConfirm('');
}
};
return (
<Box>
<Typography variant="h5">Change password</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Keep your account secure — update the default password.
</Typography>
<Box component="form" onSubmit={submit} sx={{ maxWidth: 480 }}>
{msg && (
<Alert severity={msg.ok ? 'success' : 'error'} sx={{ mb: 2 }}>
{msg.text}
</Alert>
)}
<Stack spacing={2.5}>
<TextField label="Current password" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} required fullWidth />
<TextField label="New password" type="password" value={next} onChange={(e) => setNext(e.target.value)} required fullWidth />
<TextField label="Confirm new password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required fullWidth />
<Box>
<Button type="submit" variant="contained" disabled={busy}>
{busy ? 'Updating…' : 'Update password'}
</Button>
</Box>
</Stack>
</Box>
</Box>
);
}
+165
View File
@@ -0,0 +1,165 @@
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
export type Field = {
key: string;
label: string;
type?: 'text' | 'number' | 'multiline' | 'boolean';
helper?: string;
};
type Row = { id: number } & Record<string, unknown>;
export function CrudManager<T extends Row>({
title,
description,
rows,
fields,
empty,
renderPrimary,
renderSecondary,
onCreate,
onUpdate,
onDelete,
}: {
title: string;
description?: string;
rows: T[];
fields: Field[];
empty: Record<string, unknown>;
renderPrimary: (r: T) => string;
renderSecondary?: (r: T) => string;
onCreate: (data: Record<string, unknown>) => Promise<void>;
onUpdate: (id: number, data: Record<string, unknown>) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const startCreate = () => {
setEditing(null);
setDraft({ ...empty });
setOpen(true);
};
const startEdit = (r: T) => {
setEditing(r);
setDraft({ ...r });
setOpen(true);
};
const save = async () => {
setSaving(true);
try {
if (editing) await onUpdate(editing.id, draft);
else await onCreate(draft);
setOpen(false);
} finally {
setSaving(false);
}
};
const setField = (f: Field, value: unknown) => setDraft((d) => ({ ...d, [f.key]: value }));
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="h5">{title}</Typography>
{description && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
)}
</Box>
<Button variant="contained" startIcon={<AddRoundedIcon />} onClick={startCreate}>
Add new
</Button>
</Box>
<Stack spacing={1.5}>
{rows.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Nothing here yet — add your first entry.
</Typography>
)}
{rows.map((r) => (
<Card key={r.id}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 1.5 }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{renderPrimary(r)}
</Typography>
{renderSecondary && (
<Typography variant="body2" sx={{ color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{renderSecondary(r)}
</Typography>
)}
</Box>
<IconButton onClick={() => startEdit(r)} aria-label="Edit">
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton color="error" onClick={() => onDelete(r.id)} aria-label="Delete">
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</CardContent>
</Card>
))}
</Stack>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editing ? `Edit ${title.toLowerCase()}` : `Add ${title.toLowerCase()}`}</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ mt: 1 }}>
{fields.map((f) =>
f.type === 'boolean' ? (
<FormControlLabel
key={f.key}
control={<Switch checked={Boolean(draft[f.key])} onChange={(e) => setField(f, e.target.checked)} />}
label={f.label}
/>
) : (
<TextField
key={f.key}
label={f.label}
helperText={f.helper}
type={f.type === 'number' ? 'number' : 'text'}
multiline={f.type === 'multiline'}
minRows={f.type === 'multiline' ? 3 : undefined}
value={(draft[f.key] as string | number) ?? ''}
onChange={(e) =>
setField(f, f.type === 'number' ? Number(e.target.value) : e.target.value)
}
fullWidth
/>
),
)}
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+109
View File
@@ -0,0 +1,109 @@
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import MarkEmailReadRoundedIcon from '@mui/icons-material/MarkEmailReadRounded';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { deleteEnquiry, listEnquiries, patchEnquiry } from '../../services/content';
import { SEED, type Enquiry } from '../../services/mockData';
export function EnquiriesPanel() {
const [rows, setRows] = useState<Enquiry[]>([]);
const [loaded, setLoaded] = useState(false);
const load = () => {
listEnquiries()
.then((d) => {
setRows(d);
setLoaded(true);
})
.catch(() => {
// Preview / offline: show the seed so the panel isn't empty.
setRows(SEED.enquiries);
setLoaded(true);
});
};
useEffect(load, []);
const toggleRead = async (e: Enquiry) => {
setRows((rs) => rs.map((r) => (r.id === e.id ? { ...r, is_read: !r.is_read } : r)));
try {
await patchEnquiry(e.id, !e.is_read);
} catch {
/* optimistic */
}
};
const remove = async (e: Enquiry) => {
setRows((rs) => rs.filter((r) => r.id !== e.id));
try {
await deleteEnquiry(e.id);
} catch {
/* optimistic */
}
};
const unread = rows.filter((r) => !r.is_read).length;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Typography variant="h5">Enquiries</Typography>
{unread > 0 && <Chip label={`${unread} unread`} color="secondary" size="small" />}
</Box>
<Stack spacing={1.5}>
{loaded && rows.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No enquiries yet. Messages from your contact form will appear here.
</Typography>
)}
{rows.map((e) => (
<Card key={e.id} sx={{ borderColor: e.is_read ? 'divider' : 'secondary.main' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{e.name}
{!e.is_read && (
<Chip label="New" color="secondary" size="small" sx={{ ml: 1, height: 20 }} />
)}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{[e.email, e.phone].filter(Boolean).join(' · ')}
{e.subject_interest ? ` · Interested in ${e.subject_interest}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={e.is_read ? 'Mark as unread' : 'Mark as read'}>
<IconButton onClick={() => toggleRead(e)} color={e.is_read ? 'default' : 'primary'}>
<MarkEmailReadRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton color="error" onClick={() => remove(e)}>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Typography variant="body1" sx={{ mt: 1.5 }}>
{e.message}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 1, display: 'block' }}>
{new Date(e.created_at).toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}
+119
View File
@@ -0,0 +1,119 @@
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { deleteReview, listAdminReviews, patchReview } from '../../services/content';
import { SEED, type Review } from '../../services/mockData';
export function ReviewsPanel() {
const [rows, setRows] = useState<Review[]>([]);
const [loaded, setLoaded] = useState(false);
const load = () => {
listAdminReviews()
.then((d) => {
setRows(d);
setLoaded(true);
})
.catch(() => {
// Preview / offline: show the seed so the panel isn't empty.
setRows(SEED.reviews);
setLoaded(true);
});
};
useEffect(load, []);
const toggleApprove = async (r: Review) => {
setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, is_approved: !x.is_approved } : x)));
try {
await patchReview(r.id, !r.is_approved);
} catch {
/* optimistic */
}
};
const remove = async (r: Review) => {
setRows((rs) => rs.filter((x) => x.id !== r.id));
try {
await deleteReview(r.id);
} catch {
/* optimistic */
}
};
const pending = rows.filter((r) => !r.is_approved).length;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Typography variant="h5">Reviews</Typography>
{pending > 0 && <Chip label={`${pending} pending approval`} color="secondary" size="small" />}
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Feedback from students and parents. Only reviews you approve are shown on the website.
</Typography>
<Stack spacing={1.5}>
{loaded && rows.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No reviews yet. Feedback submitted from the site's footer will appear here.
</Typography>
)}
{rows.map((r) => (
<Card key={r.id} sx={{ borderColor: r.is_approved ? 'success.main' : 'secondary.main' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{r.author_name}
<Chip
label={r.is_approved ? 'Approved · live on site' : 'Pending'}
color={r.is_approved ? 'success' : 'secondary'}
size="small"
sx={{ ml: 1, height: 20 }}
/>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{r.relation}
</Typography>
<Rating value={r.rating} readOnly size="small" />
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={r.is_approved ? 'Unapprove (hide from site)' : 'Approve (show on site)'}>
<IconButton onClick={() => toggleApprove(r)} color={r.is_approved ? 'default' : 'success'}>
{r.is_approved ? <UndoRoundedIcon fontSize="small" /> : <CheckCircleRoundedIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton color="error" onClick={() => remove(r)}>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Typography variant="body1" sx={{ mt: 1.5 }}>
{r.message}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 1, display: 'block' }}>
{new Date(r.created_at).toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}
+265
View File
@@ -0,0 +1,265 @@
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import EventAvailableRoundedIcon from '@mui/icons-material/EventAvailableRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import {
getStudentSchedule,
getTodaySchedule,
listStudents,
setStudentSchedule,
} from '../../services/content';
import { SEED, type ScheduleSlot, type Student, type TodayEntry } from '../../services/mockData';
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; // index = weekday (Mon=0)
type Slot = Omit<ScheduleSlot, 'id' | 'student_id'>;
const newSlot = (): Slot => ({ weekday: 0, start_time: '16:00', end_time: '17:00' });
function computeToday(students: Student[], schedules: Record<number, ScheduleSlot[]>): TodayEntry[] {
const jsDay = new Date().getDay(); // Sun=0..Sat=6
const weekday = (jsDay + 6) % 7; // convert to Mon=0..Sun=6
const entries: TodayEntry[] = [];
students
.filter((s) => s.is_active)
.forEach((s) => {
(schedules[s.id] ?? []).forEach((slot) => {
if (slot.weekday === weekday) {
entries.push({
student_id: s.id,
student_name: s.name,
subject: s.subject,
start_time: slot.start_time,
end_time: slot.end_time,
});
}
});
});
return entries.sort((a, b) => a.start_time.localeCompare(b.start_time));
}
export function SchedulePanel() {
const [students, setStudents] = useState<Student[]>([]);
const [schedules, setSchedules] = useState<Record<number, ScheduleSlot[]>>({});
const [today, setToday] = useState<TodayEntry[]>([]);
const [editing, setEditing] = useState<Student | null>(null);
const [draft, setDraft] = useState<Slot[]>([]);
const [saving, setSaving] = useState(false);
const [live, setLive] = useState(false);
useEffect(() => {
// Try live: students + per-student schedules; fall back to SEED offline.
listStudents()
.then(async (studs) => {
setStudents(studs);
setLive(true);
const map: Record<number, ScheduleSlot[]> = {};
await Promise.all(
studs.map(async (s) => {
try {
map[s.id] = await getStudentSchedule(s.id);
} catch {
map[s.id] = [];
}
}),
);
setSchedules(map);
getTodaySchedule()
.then(setToday)
.catch(() => setToday(computeToday(studs, map)));
})
.catch(() => {
setStudents(SEED.students);
setSchedules(SEED.schedule);
setToday(computeToday(SEED.students, SEED.schedule));
setLive(false);
});
}, []);
const openEditor = (s: Student) => {
setEditing(s);
setDraft((schedules[s.id] ?? []).map((sl) => ({ weekday: sl.weekday, start_time: sl.start_time, end_time: sl.end_time })));
};
const addRow = () => setDraft((d) => [...d, newSlot()]);
const removeRow = (i: number) => setDraft((d) => d.filter((_, idx) => idx !== i));
const setRow = (i: number, patch: Partial<Slot>) =>
setDraft((d) => d.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
const save = async () => {
if (!editing) return;
setSaving(true);
const sid = editing.id;
// Local update first so preview stays consistent.
const asSlots: ScheduleSlot[] = draft.map((sl, i) => ({ ...sl, id: i + 1, student_id: sid }));
const nextSchedules = { ...schedules, [sid]: asSlots };
try {
if (live) {
const saved = await setStudentSchedule(sid, draft);
nextSchedules[sid] = saved;
}
} catch {
/* optimistic */
} finally {
setSchedules(nextSchedules);
setToday(computeToday(students, nextSchedules));
setSaving(false);
setEditing(null);
}
};
const slotSummary = (s: Student) => {
const slots = schedules[s.id] ?? [];
if (slots.length === 0) return 'No schedule set';
return [...slots]
.sort((a, b) => a.weekday - b.weekday || a.start_time.localeCompare(b.start_time))
.map((sl) => `${DAYS[sl.weekday].slice(0, 3)} ${sl.start_time}`)
.join(' · ');
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Schedule
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 3 }}>
Set which days and times you teach each student. Check the Today list each morning.
</Typography>
{/* Today */}
<Card sx={{ mb: 3, borderColor: 'primary.main' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<EventAvailableRoundedIcon color="primary" />
<Typography variant="h6">Today — {DAYS[(new Date().getDay() + 6) % 7]}</Typography>
</Box>
{today.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No classes scheduled today (or the day is marked off).
</Typography>
) : (
<Stack spacing={1}>
{today.map((t, i) => (
<Box
key={`${t.student_id}-${i}`}
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1, borderRadius: 2, bgcolor: 'action.hover' }}
>
<Chip label={`${t.start_time}–${t.end_time}`} color="primary" size="small" />
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t.student_name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t.subject}
</Typography>
</Box>
</Box>
))}
</Stack>
)}
</CardContent>
</Card>
{/* Per-student schedules */}
<Stack spacing={1.5}>
{students.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Add students first, then set their weekly schedule here.
</Typography>
)}
{students.map((s) => (
<Card key={s.id}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 1.5 }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{s.name}
{!s.is_active && <Chip label="Paused" size="small" sx={{ ml: 1 }} variant="outlined" />}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{slotSummary(s)}
</Typography>
</Box>
<Button variant="outlined" size="small" onClick={() => openEditor(s)}>
Edit schedule
</Button>
</CardContent>
</Card>
))}
</Stack>
<Dialog open={Boolean(editing)} onClose={() => setEditing(null)} maxWidth="sm" fullWidth>
<DialogTitle>Schedule — {editing?.name}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{draft.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No slots yet. Add the days and times you teach this student.
</Typography>
)}
{draft.map((row, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
select
label="Day"
size="small"
value={row.weekday}
onChange={(e) => setRow(i, { weekday: Number(e.target.value) })}
sx={{ minWidth: 130 }}
>
{DAYS.map((d, idx) => (
<MenuItem key={idx} value={idx}>
{d}
</MenuItem>
))}
</TextField>
<TextField
label="Start"
type="time"
size="small"
value={row.start_time}
onChange={(e) => setRow(i, { start_time: e.target.value })}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="End"
type="time"
size="small"
value={row.end_time}
onChange={(e) => setRow(i, { end_time: e.target.value })}
InputLabelProps={{ shrink: true }}
/>
<IconButton color="error" onClick={() => removeRow(i)} aria-label="Remove slot">
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Box>
))}
<Button startIcon={<AddRoundedIcon />} onClick={addRow} sx={{ alignSelf: 'flex-start' }}>
Add a slot
</Button>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setEditing(null)}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save schedule'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
@@ -0,0 +1,95 @@
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Snackbar from '@mui/material/Snackbar';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import type { Field } from './CrudManager';
// A form for a single record (profile / settings). Two-column responsive grid.
export function SingletonEditor<T extends Record<string, unknown>>({
title,
description,
value,
fields,
onSave,
}: {
title: string;
description?: string;
value: T;
fields: Field[];
onSave: (data: T) => Promise<void>;
}) {
const [draft, setDraft] = useState<T>(value);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<{ open: boolean; msg: string; ok: boolean }>({ open: false, msg: '', ok: true });
const set = (key: string, v: unknown) => setDraft((d) => ({ ...d, [key]: v }));
const save = async () => {
setSaving(true);
try {
await onSave(draft);
setToast({ open: true, msg: 'Saved successfully', ok: true });
} catch (e) {
setToast({ open: true, msg: e instanceof Error ? e.message : 'Could not save', ok: false });
} finally {
setSaving(false);
}
};
return (
<Box>
<Typography variant="h5">{title}</Typography>
{description && (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{description}
</Typography>
)}
<Stack spacing={2.5} sx={{ maxWidth: 720 }}>
{fields.map((f) =>
f.type === 'number' ? (
<TextField
key={f.key}
label={f.label}
type="number"
value={(draft[f.key] as number) ?? 0}
onChange={(e) => set(f.key, Number(e.target.value))}
helperText={f.helper}
fullWidth
/>
) : (
<TextField
key={f.key}
label={f.label}
value={(draft[f.key] as string) ?? ''}
onChange={(e) => set(f.key, e.target.value)}
helperText={f.helper}
multiline={f.type === 'multiline'}
minRows={f.type === 'multiline' ? 3 : undefined}
fullWidth
/>
),
)}
<Box>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</Box>
</Stack>
<Snackbar
open={toast.open}
autoHideDuration={3000}
onClose={() => setToast((t) => ({ ...t, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={toast.ok ? 'success' : 'error'} variant="filled">
{toast.msg}
</Alert>
</Snackbar>
</Box>
);
}
+271
View File
@@ -0,0 +1,271 @@
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import GroupsRoundedIcon from '@mui/icons-material/GroupsRounded';
import PaymentsRoundedIcon from '@mui/icons-material/PaymentsRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import {
createStudent,
deleteStudent,
listStudents,
updateStudent,
} from '../../services/content';
import { SEED, type Student } from '../../services/mockData';
export const rupees = (n: number) => `₹${(n || 0).toLocaleString('en-IN')}`;
const todayStr = () => new Date().toISOString().slice(0, 10);
const emptyStudent = (): Partial<Student> => ({
name: '',
phone: '',
parent_name: '',
parent_phone: '',
address: '',
subject: '',
monthly_fee: 0,
start_date: todayStr(),
is_active: true,
notes: '',
});
export function StudentsPanel() {
const [rows, setRows] = useState<Student[]>([]);
const [loaded, setLoaded] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Student | null>(null);
const [draft, setDraft] = useState<Partial<Student>>(emptyStudent());
const [saving, setSaving] = useState(false);
const load = () => {
listStudents()
.then((d) => {
setRows(d);
setLoaded(true);
})
.catch(() => {
setRows(SEED.students);
setLoaded(true);
});
};
useEffect(load, []);
const startCreate = () => {
setEditing(null);
setDraft(emptyStudent());
setOpen(true);
};
const startEdit = (s: Student) => {
setEditing(s);
setDraft({ ...s });
setOpen(true);
};
const save = async () => {
setSaving(true);
try {
if (editing) await updateStudent(editing.id, draft);
else await createStudent(draft);
setOpen(false);
load();
} catch {
// Offline / preview: optimistic local update so the panel stays usable.
if (editing) {
setRows((rs) => rs.map((r) => (r.id === editing.id ? ({ ...r, ...draft } as Student) : r)));
} else {
const id = Math.max(0, ...rows.map((r) => r.id)) + 1;
setRows((rs) => [...rs, { ...(emptyStudent() as Student), ...draft, id }]);
}
setOpen(false);
} finally {
setSaving(false);
}
};
const remove = async (s: Student) => {
setRows((rs) => rs.filter((r) => r.id !== s.id));
try {
await deleteStudent(s.id);
} catch {
/* optimistic */
}
};
const set = <K extends keyof Student>(k: K, v: Student[K]) => setDraft((d) => ({ ...d, [k]: v }));
const activeStudents = rows.filter((r) => r.is_active);
const expectedIncome = activeStudents.reduce((sum, r) => sum + (r.monthly_fee || 0), 0);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="h5">Students</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Every student and parent record. Fees add up to your expected monthly income.
</Typography>
</Box>
<Button variant="contained" startIcon={<AddRoundedIcon />} onClick={startCreate}>
Add student
</Button>
</Box>
{/* Summary cards */}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Card sx={{ flex: 1 }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<GroupsRoundedIcon color="primary" fontSize="large" />
<Box>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{activeStudents.length}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Active students
</Typography>
</Box>
</CardContent>
</Card>
<Card sx={{ flex: 1, bgcolor: 'primary.main', color: 'primary.contrastText' }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<PaymentsRoundedIcon fontSize="large" />
<Box>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{rupees(expectedIncome)}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Expected this month
</Typography>
</Box>
</CardContent>
</Card>
</Stack>
<Card>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Student</TableCell>
<TableCell>Subject / Class</TableCell>
<TableCell>Parent</TableCell>
<TableCell align="right">Fee</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loaded && rows.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<Typography variant="body2" sx={{ color: 'text.secondary', py: 2 }}>
No students yet — add your first student.
</Typography>
</TableCell>
</TableRow>
)}
{rows.map((s) => (
<TableRow key={s.id} hover>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{s.name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{s.phone}
</Typography>
</TableCell>
<TableCell>{s.subject}</TableCell>
<TableCell>
<Typography variant="body2">{s.parent_name}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{s.parent_phone}
</Typography>
</TableCell>
<TableCell align="right">{rupees(s.monthly_fee)}</TableCell>
<TableCell align="center">
<Chip
size="small"
label={s.is_active ? 'Active' : 'Paused'}
color={s.is_active ? 'success' : 'default'}
variant={s.is_active ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => startEdit(s)} aria-label="Edit">
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => remove(s)} aria-label="Delete">
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editing ? 'Edit student' : 'Add student'}</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ mt: 1 }}>
<TextField label="Student name" value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} fullWidth autoFocus />
<TextField label="Student phone" value={draft.phone ?? ''} onChange={(e) => set('phone', e.target.value)} fullWidth />
<TextField label="Subject / Class" helperText="e.g. Mathematics · Class 10" value={draft.subject ?? ''} onChange={(e) => set('subject', e.target.value)} fullWidth />
<TextField label="Parent name" value={draft.parent_name ?? ''} onChange={(e) => set('parent_name', e.target.value)} fullWidth />
<TextField label="Parent phone" value={draft.parent_phone ?? ''} onChange={(e) => set('parent_phone', e.target.value)} fullWidth />
<TextField label="Address" multiline minRows={2} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} fullWidth />
<TextField
label="Monthly fee (₹)"
type="number"
value={draft.monthly_fee ?? 0}
onChange={(e) => set('monthly_fee', Number(e.target.value))}
fullWidth
/>
<TextField
label="Start date"
type="date"
value={(draft.start_date ?? todayStr()).slice(0, 10)}
onChange={(e) => set('start_date', e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField label="Notes" multiline minRows={2} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} fullWidth />
<FormControlLabel
control={<Switch checked={Boolean(draft.is_active)} onChange={(e) => set('is_active', e.target.checked)} />}
label="Active (uncheck to pause / stop counting fee)"
/>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={saving || !draft.name}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* The HTTP client. One place that knows the base URL.
*
* Deliberately dependency-free: the scaffold installs no axios, so an `api.ts`
* that imported it would fail to build. The shape (`.get(...).then(r => r.data)`)
* matches what the fullstack skill's examples use.
*
* VITE_API_URL is the backend BASE, wired at deploy time. Endpoints live under
* `/api`. Never hardcode localhost — it works in dev and breaks on deploy.
*/
const BASE = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
export type Res<T> = { data: T; status: number };
// Set by an auth store (the `auth` block) — every request then carries it as a
// Bearer header. Nothing else in the app needs to know how auth travels.
let authToken: string | null = null;
export const setAuthToken = (token: string | null): void => {
authToken = token;
};
async function request<T>(method: string, path: string, body?: unknown): Promise<Res<T>> {
const headers: Record<string, string> = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail = `${res.status} ${res.statusText}`;
try {
const j = (await res.json()) as { detail?: unknown };
if (typeof j.detail === 'string') detail = j.detail;
} catch {
/* keep the status line */
}
throw new Error(detail);
}
// 204 has no body — reading it as JSON throws, and a delete that "fails"
// because it succeeded is a confusing bug to chase.
const data = res.status === 204 ? (undefined as T) : ((await res.json()) as T);
return { data, status: res.status };
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body ?? {}),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body ?? {}),
delete: <T>(path: string) => request<T>('DELETE', path),
};
export const apiBaseUrl = () => BASE;
+131
View File
@@ -0,0 +1,131 @@
// Data access for the public site + admin. Public reads go through `resolve()`
// so Preview (no backend) still renders the seed. Admin writes always hit the
// real API (they only run when logged in against a live backend).
import { api } from './api';
import { resolve } from './dataSource';
import {
SEED,
type Attendance,
type Course,
type DayMark,
type Enquiry,
type FeePlan,
type Profile,
type Review,
type ScheduleSlot,
type SiteSettings,
type Student,
type Subject,
type Summary,
type Testimonial,
type TodayEntry,
} from './mockData';
export const getProfile = () =>
resolve(() => api.get<Profile>('/api/profile').then((r) => r.data), SEED.profile);
export const getSubjects = () =>
resolve(() => api.get<Subject[]>('/api/subjects').then((r) => r.data), SEED.subjects);
export const getCourses = () =>
resolve(() => api.get<Course[]>('/api/courses').then((r) => r.data), SEED.courses);
export const getFees = () =>
resolve(() => api.get<FeePlan[]>('/api/fees').then((r) => r.data), SEED.fees);
export const getTestimonials = () =>
resolve(() => api.get<Testimonial[]>('/api/testimonials').then((r) => r.data), SEED.testimonials);
export const getSettings = () =>
resolve(() => api.get<SiteSettings>('/api/settings').then((r) => r.data), SEED.settings);
// Public reviews: only approved ones come back from the API.
export const getReviews = () =>
resolve(
() => api.get<Review[]>('/api/reviews').then((r) => r.data),
SEED.reviews.filter((r) => r.is_approved),
);
export const submitReview = (body: {
author_name: string;
relation: string;
rating: number;
message: string;
}) => api.post<Review>('/api/reviews', body).then((r) => r.data);
export const submitEnquiry = (body: {
name: string;
email: string;
phone: string;
subject_interest: string;
message: string;
}) => api.post<Enquiry>('/api/enquiries', body).then((r) => r.data);
// ---- Admin writes (live backend only) ----
export const updateProfile = (body: Profile) =>
api.put<Profile>('/api/admin/profile', body).then((r) => r.data);
export const updateSettings = (body: SiteSettings) =>
api.put<SiteSettings>('/api/admin/settings', body).then((r) => r.data);
export const createSubject = (b: Partial<Subject>) => api.post<Subject>('/api/admin/subjects', b).then((r) => r.data);
export const updateSubject = (id: number, b: Partial<Subject>) => api.put<Subject>(`/api/admin/subjects/${id}`, b).then((r) => r.data);
export const deleteSubject = (id: number) => api.delete(`/api/admin/subjects/${id}`);
export const createCourse = (b: Partial<Course>) => api.post<Course>('/api/admin/courses', b).then((r) => r.data);
export const updateCourse = (id: number, b: Partial<Course>) => api.put<Course>(`/api/admin/courses/${id}`, b).then((r) => r.data);
export const deleteCourse = (id: number) => api.delete(`/api/admin/courses/${id}`);
export const createFee = (b: Partial<FeePlan>) => api.post<FeePlan>('/api/admin/fees', b).then((r) => r.data);
export const updateFee = (id: number, b: Partial<FeePlan>) => api.put<FeePlan>(`/api/admin/fees/${id}`, b).then((r) => r.data);
export const deleteFee = (id: number) => api.delete(`/api/admin/fees/${id}`);
export const createTestimonial = (b: Partial<Testimonial>) => api.post<Testimonial>('/api/admin/testimonials', b).then((r) => r.data);
export const updateTestimonial = (id: number, b: Partial<Testimonial>) => api.put<Testimonial>(`/api/admin/testimonials/${id}`, b).then((r) => r.data);
export const deleteTestimonial = (id: number) => api.delete(`/api/admin/testimonials/${id}`);
export const listEnquiries = () => api.get<Enquiry[]>('/api/admin/enquiries').then((r) => r.data);
export const patchEnquiry = (id: number, is_read: boolean) => api.patch<Enquiry>(`/api/admin/enquiries/${id}`, { is_read }).then((r) => r.data);
export const deleteEnquiry = (id: number) => api.delete(`/api/admin/enquiries/${id}`);
export const listAdminReviews = () => api.get<Review[]>('/api/admin/reviews').then((r) => r.data);
export const patchReview = (id: number, is_approved: boolean) => api.patch<Review>(`/api/admin/reviews/${id}`, { is_approved }).then((r) => r.data);
export const deleteReview = (id: number) => api.delete(`/api/admin/reviews/${id}`);
// ---- Student management (live backend only; panels fall back to SEED offline) ----
export const listStudents = () => api.get<Student[]>('/api/admin/students').then((r) => r.data);
export const createStudent = (b: Partial<Student>) => api.post<Student>('/api/admin/students', b).then((r) => r.data);
export const updateStudent = (id: number, b: Partial<Student>) => api.put<Student>(`/api/admin/students/${id}`, b).then((r) => r.data);
export const deleteStudent = (id: number) => api.delete(`/api/admin/students/${id}`);
export const getStudentSchedule = (id: number) =>
api.get<ScheduleSlot[]>(`/api/admin/students/${id}/schedule`).then((r) => r.data);
export const setStudentSchedule = (id: number, slots: Omit<ScheduleSlot, 'id' | 'student_id'>[]) =>
api.put<ScheduleSlot[]>(`/api/admin/students/${id}/schedule`, slots).then((r) => r.data);
export const getTodaySchedule = () =>
api.get<TodayEntry[]>('/api/admin/schedule/today').then((r) => r.data);
export const getAttendance = (month: string) =>
api
.get<{ attendance: Attendance[]; day_marks: DayMark[] }>(
`/api/admin/attendance?month=${encodeURIComponent(month)}`,
)
.then((r) => r.data);
export const setAttendance = (body: {
student_id: number;
date: string;
status: string;
note?: string;
}) => api.put<Attendance>('/api/admin/attendance', body).then((r) => r.data);
export const clearAttendance = (body: { student_id: number; date: string }) =>
api.post('/api/admin/attendance/clear', body);
export const setDayMark = (body: { date: string; kind: string; note?: string }) =>
api.post<DayMark>('/api/admin/day-marks', body).then((r) => r.data);
export const deleteDayMark = (onDate: string) => api.delete(`/api/admin/day-marks/${onDate}`);
export const getStudentSummary = (month: string) =>
api
.get<Summary>(`/api/admin/students/summary?month=${encodeURIComponent(month)}`)
.then((r) => r.data);
+74
View File
@@ -0,0 +1,74 @@
/**
* Mock-first data resolution.
*
* The Preview tab runs this frontend ALONE, in a browser sandbox, with no
* backend behind it. A UI whose every screen starts with a fetch shows an
* error or an empty list there — which is what the app is judged by, seconds
* after the build says it finished.
*
* So every data call goes through `resolve(real, mock)`: it serves the mock
* until a health check says the backend is answering, then serves the real
* call. No code change, no reload, no flag to flip.
*
* const todos = await resolve(
* () => api.get<Todo[]>('/api/todos').then((r) => r.data),
* SEED.todos,
* );
*
* The seed rows live in ./mockData.ts. src/App.tsx (React) / src/views (Vue)
* already do this — copy that call, do not invent a second way.
*/
import { api } from './api';
/** Re-checked rather than cached forever: the backend appears mid-session, and
* a resolver that decided "no backend" once would never notice. */
const TTL_MS = 5_000;
let liveUntil = 0;
let live = false;
let inFlight: Promise<boolean> | null = null;
async function probe(): Promise<boolean> {
try {
const res = await api.get<unknown>('/api/health');
return res.status >= 200 && res.status < 300;
} catch {
return false;
}
}
/** Whether the backend is currently answering. Shared across callers, so ten
* components mounting together make one health request, not ten. */
export async function backendIsLive(): Promise<boolean> {
if (Date.now() < liveUntil) return live;
if (!inFlight) {
inFlight = probe().then((ok) => {
live = ok;
liveUntil = Date.now() + TTL_MS;
inFlight = null;
return ok;
});
}
return inFlight;
}
/**
* The real call when the backend is up, the mock otherwise.
*
* A real call that THROWS also falls back, rather than propagating: a backend
* that is up but broken should still leave a usable screen, and the caller
* cannot tell the difference anyway.
*/
export async function resolve<T>(real: () => Promise<T>, mock: T): Promise<T> {
if (!(await backendIsLive())) return mock;
try {
return await real();
} catch {
return mock;
}
}
/** For a status chip: "Demo data" vs "Live". Never leave the user guessing
* which one they are looking at. */
export const dataSourceLabel = async (): Promise<'live' | 'demo'> =>
(await backendIsLive()) ? 'live' : 'demo';
+277
View File
@@ -0,0 +1,277 @@
// Seed data mirrors the backend's DEVTeacher placeholder content, so Preview
// (which has no backend) renders a complete, real-looking site.
export type Profile = {
id: number;
name: string;
tagline: string;
bio: string;
photo_url: string;
qualifications: string;
experience_years: number;
students_taught: number;
achievements: string;
hero_cta: string;
};
export type Subject = {
id: number;
name: string;
level: string;
description: string;
mode: string;
icon: string;
sort_order: number;
};
export type Course = {
id: number;
title: string;
subject: string;
description: string;
duration: string;
mode: string;
highlights: string;
sort_order: number;
};
export type FeePlan = {
id: number;
name: string;
price: string;
period: string;
features: string;
highlighted: boolean;
sort_order: number;
};
export type Testimonial = {
id: number;
author_name: string;
relation: string;
quote: string;
rating: number;
sort_order: number;
};
export type SiteSettings = {
id: number;
address: string;
phone: string;
email: string;
hours: string;
whatsapp: string;
instagram: string;
youtube: string;
facebook: string;
footer_text: string;
};
export type Review = {
id: number;
author_name: string;
relation: string;
rating: number;
message: string;
is_approved: boolean;
created_at: string;
};
export type Enquiry = {
id: number;
name: string;
email: string;
phone: string;
subject_interest: string;
message: string;
created_at: string;
is_read: boolean;
};
// ---- Student management ----
export type Student = {
id: number;
name: string;
phone: string;
parent_name: string;
parent_phone: string;
address: string;
subject: string;
monthly_fee: number;
start_date: string; // YYYY-MM-DD
is_active: boolean;
notes: string;
};
export type ScheduleSlot = {
id: number;
student_id: number;
weekday: number; // 0=Mon .. 6=Sun
start_time: string; // HH:MM
end_time: string;
};
export type AttendanceStatus = 'present' | 'absent' | 'off';
export type Attendance = {
id: number;
student_id: number;
date: string; // YYYY-MM-DD
status: AttendanceStatus;
note: string; // why absent / off
};
export type DayMarkKind = 'holiday' | 'class_off';
export type DayMark = {
id: number;
date: string; // YYYY-MM-DD
kind: DayMarkKind;
note: string;
};
export type TodayEntry = {
student_id: number;
student_name: string;
subject: string;
start_time: string;
end_time: string;
};
export type StudentSummary = {
student_id: number;
student_name: string;
monthly_fee: number;
present: number;
absent: number;
off: number;
};
export type Summary = {
month: string;
active_students: number;
expected_income: number;
students: StudentSummary[];
};
const _today = new Date();
const _pad = (n: number) => String(n).padStart(2, '0');
const _ymd = (y: number, m: number, d: number) => `${y}-${_pad(m)}-${_pad(d)}`;
const _thisMonth = { y: _today.getFullYear(), m: _today.getMonth() + 1 };
export const SEED = {
profile: {
id: 1,
name: 'Swati Yadav',
tagline: 'Making learning joyful, clear and exam-ready',
bio:
"Hello! I'm Swati Yadav, a passionate tuition teacher with years of experience helping students build strong fundamentals and real confidence. I focus on concept clarity, regular practice and personal attention so every student can reach their full potential — whether the goal is topping the class, cracking board exams, or simply falling in love with a subject.",
photo_url: '',
qualifications: 'M.Sc. Mathematics · B.Ed. · Certified Online Educator',
experience_years: 10,
students_taught: 500,
achievements:
'95% of students improved by 2+ grades\nMentored 50+ board-exam toppers\nPersonalised study plans for every learner',
hero_cta: 'Book a free demo class',
} as Profile,
subjects: [
{ id: 1, name: 'Mathematics', level: 'Class 6–12', description: 'Algebra, geometry, calculus and problem-solving made simple with step-by-step methods.', mode: 'Online & Offline', icon: '📐', sort_order: 1 },
{ id: 2, name: 'Physics', level: 'Class 9–12', description: 'Concept-first physics with real-life examples, numericals and exam techniques.', mode: 'Online & Offline', icon: '🔬', sort_order: 2 },
{ id: 3, name: 'Chemistry', level: 'Class 9–12', description: 'Organic, inorganic and physical chemistry taught with clarity and easy recall tricks.', mode: 'Online & Offline', icon: '⚗️', sort_order: 3 },
{ id: 4, name: 'English', level: 'Class 6–10', description: 'Grammar, comprehension, writing skills and confident communication.', mode: 'Online & Offline', icon: '📖', sort_order: 4 },
] as Subject[],
courses: [
{ id: 1, title: 'Board Exam Crash Course', subject: 'Mathematics & Science', description: 'An intensive revision programme covering the complete syllabus with mock tests, doubt-clearing and previous-year papers.', duration: '3 months', mode: 'Online & Offline', highlights: 'Practice tests every week\nPersonal doubt sessions\nComplete syllabus revision', sort_order: 1 },
{ id: 2, title: 'Foundation Builder', subject: 'All Subjects', description: 'A steady long-term course for younger students that builds strong basics and study habits from the ground up.', duration: 'Full academic year', mode: 'Online & Offline', highlights: 'Concept-first teaching\nRegular homework & feedback\nMonthly progress reports', sort_order: 2 },
{ id: 3, title: 'One-on-One Personal Tuition', subject: 'Chosen Subject', description: "Fully personalised private tuition tailored to a single student's pace, goals and weak areas.", duration: 'Flexible', mode: 'Online & Offline', highlights: 'Completely customised plan\nFlexible timings\nDedicated attention', sort_order: 3 },
] as Course[],
fees: [
{ id: 1, name: 'Group Batch', price: '₹1,500', period: 'per month', features: 'Small batches (max 8)\n3 classes a week\nWeekly tests\nStudy material included', highlighted: false, sort_order: 1 },
{ id: 2, name: 'Premium Batch', price: '₹2,500', period: 'per month', features: 'Mini batches (max 4)\n5 classes a week\nWeekly tests + doubt sessions\nMonthly progress report', highlighted: true, sort_order: 2 },
{ id: 3, name: '1-on-1 Private', price: '₹4,000', period: 'per month', features: 'Fully personal tuition\nFlexible timings\nCustom study plan\nUnlimited doubt support', highlighted: false, sort_order: 3 },
] as FeePlan[],
testimonials: [
{ id: 1, author_name: 'Aarav Sharma', relation: 'Student', quote: "Swati ma'am made maths so easy to understand. My marks jumped from 62 to 91 in just one year!", rating: 5, sort_order: 1 },
{ id: 2, author_name: 'Meena Gupta', relation: 'Parent', quote: 'My daughter actually looks forward to her classes now. The personal attention is wonderful.', rating: 5, sort_order: 2 },
{ id: 3, author_name: 'Rohan Verma', relation: 'Student', quote: 'The best teacher I\'ve ever had. She explains every concept until it truly clicks.', rating: 5, sort_order: 3 },
{ id: 4, author_name: 'Kavita Nair', relation: 'Parent', quote: 'Very disciplined, caring and result-oriented. Highly recommend to every parent.', rating: 5, sort_order: 4 },
] as Testimonial[],
settings: {
id: 1,
address: '123 Vidya Nagar, Near City Library, Your City, 000000',
phone: '+91 90000 00000',
email: 'hello@swatiyadav.com',
hours: 'Mon–Sat, 9:00 AM – 8:00 PM',
whatsapp: 'https://wa.me/919000000000',
instagram: 'https://instagram.com/',
youtube: 'https://youtube.com/',
facebook: 'https://facebook.com/',
footer_text: 'Empowering students with knowledge, confidence and care.',
} as SiteSettings,
enquiries: [
{ id: 1, name: 'Sample Parent', email: 'parent@example.com', phone: '+91 98765 43210', subject_interest: 'Mathematics', message: "Hi, I'd like to know the timings for the Class 10 maths batch. Thank you!", created_at: new Date().toISOString(), is_read: false },
] as Enquiry[],
reviews: [
{ id: 1, author_name: 'Priya Deshmukh', relation: 'Parent', rating: 5, message: 'The weekly progress updates keep me informed and my son is far more confident now.', is_approved: true, created_at: new Date().toISOString() },
{ id: 2, author_name: 'Ananya Rao', relation: 'Student', rating: 5, message: 'Doubts are always cleared patiently. Highly recommend her classes!', is_approved: false, created_at: new Date().toISOString() },
] as Review[],
students: [
{ id: 1, name: 'Aarav Sharma', phone: '+91 98111 22233', parent_name: 'Rakesh Sharma', parent_phone: '+91 98111 00011', address: '12 Green Park, Your City', subject: 'Mathematics · Class 10', monthly_fee: 1500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
{ id: 2, name: 'Isha Verma', phone: '+91 98222 33344', parent_name: 'Sunita Verma', parent_phone: '+91 98222 00022', address: '45 Lake View, Your City', subject: 'Physics · Class 12', monthly_fee: 2500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
{ id: 3, name: 'Rohan Gupta', phone: '+91 98333 44455', parent_name: 'Manoj Gupta', parent_phone: '+91 98333 00033', address: '78 Rose Villa, Your City', subject: 'Chemistry · Class 11', monthly_fee: 2500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
{ id: 4, name: 'Ananya Nair', phone: '+91 98444 55566', parent_name: 'Kavita Nair', parent_phone: '+91 98444 00044', address: '9 Sunrise Apartments, Your City', subject: 'English · Class 8', monthly_fee: 1500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
] as Student[],
schedule: {
1: [
{ id: 1, student_id: 1, weekday: 0, start_time: '16:00', end_time: '17:00' },
{ id: 2, student_id: 1, weekday: 2, start_time: '16:00', end_time: '17:00' },
{ id: 3, student_id: 1, weekday: 4, start_time: '16:00', end_time: '17:00' },
],
2: [
{ id: 4, student_id: 2, weekday: 1, start_time: '17:30', end_time: '18:30' },
{ id: 5, student_id: 2, weekday: 3, start_time: '17:30', end_time: '18:30' },
],
3: [
{ id: 6, student_id: 3, weekday: 0, start_time: '18:00', end_time: '19:00' },
{ id: 7, student_id: 3, weekday: 3, start_time: '18:00', end_time: '19:00' },
{ id: 8, student_id: 3, weekday: 5, start_time: '10:00', end_time: '11:00' },
],
4: [
{ id: 9, student_id: 4, weekday: 1, start_time: '16:00', end_time: '17:00' },
{ id: 10, student_id: 4, weekday: 4, start_time: '16:00', end_time: '17:00' },
],
} as Record<number, ScheduleSlot[]>,
attendance: (() => {
const rows: Attendance[] = [];
const statuses: AttendanceStatus[] = ['present', 'present', 'absent', 'present', 'off'];
let id = 1;
const maxDay = Math.min(5, _today.getDate());
for (let day = 2; day <= maxDay; day += 1) {
[1, 2, 3, 4].forEach((sid, i) => {
const status = statuses[(day + i) % statuses.length];
rows.push({
id: id++,
student_id: sid,
date: _ymd(_thisMonth.y, _thisMonth.m, day),
status,
note: status === 'absent' ? 'Was unwell' : status === 'off' ? 'Family function' : '',
});
});
}
return rows;
})() as Attendance[],
dayMarks: [
{ id: 1, date: _ymd(_thisMonth.y, _thisMonth.m, 1), kind: 'holiday', note: 'Month start holiday' },
] as DayMark[],
};
View File
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { api, setAuthToken } from '../services/api';
const TOKEN_KEY = 'swatiyadav-token';
const USER_KEY = 'swatiyadav-user';
type AuthState = {
token: string | null;
username: string | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
changePassword: (current: string, next: string) => Promise<string | null>;
};
// Restore any saved session at module load so refreshing keeps you logged in.
const savedToken = typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_KEY) : null;
const savedUser = typeof window !== 'undefined' ? window.localStorage.getItem(USER_KEY) : null;
if (savedToken) setAuthToken(savedToken);
export const useAuth = create<AuthState>((set) => ({
token: savedToken,
username: savedUser,
loading: false,
error: null,
login: async (username, password) => {
set({ loading: true, error: null });
try {
const { data } = await api.post<{ access_token: string; username: string }>('/api/auth/login', {
username,
password,
});
setAuthToken(data.access_token);
window.localStorage.setItem(TOKEN_KEY, data.access_token);
window.localStorage.setItem(USER_KEY, data.username);
set({ token: data.access_token, username: data.username, loading: false });
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Login failed';
set({ loading: false, error: msg });
return false;
}
},
logout: () => {
setAuthToken(null);
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
set({ token: null, username: null, error: null });
},
changePassword: async (current, next) => {
try {
await api.post('/api/auth/change-password', { current_password: current, new_password: next });
return null;
} catch (e) {
return e instanceof Error ? e.message : 'Could not change password';
}
},
}));
+88
View File
@@ -0,0 +1,88 @@
import { createTheme, type Theme } from '@mui/material/styles';
// Design tokens for SwatiYadav — a warm, modern, feminine palette (violet +
// rose gradients) that reads well in both light and dark. The ONLY file with
// raw colour literals; everywhere else uses tokens (`color: 'text.secondary'`).
export type Mode = 'light' | 'dark';
const shared = {
spacing: 8,
shape: { borderRadius: 16 },
typography: {
fontFamily: '"Poppins", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
h1: { fontSize: '3rem', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.1 },
h2: { fontSize: '2.25rem', fontWeight: 800, letterSpacing: '-0.02em', lineHeight: 1.15 },
h3: { fontSize: '1.6rem', fontWeight: 700, letterSpacing: '-0.01em' },
h4: { fontSize: '1.3rem', fontWeight: 700 },
h5: { fontSize: '1.125rem', fontWeight: 600 },
h6: { fontSize: '1rem', fontWeight: 600 },
subtitle1: { fontSize: '1.05rem', lineHeight: 1.6 },
body1: { fontSize: '1rem', lineHeight: 1.7 },
body2: { fontSize: '0.9rem', lineHeight: 1.6 },
button: { textTransform: 'none' as const, fontWeight: 600 },
},
} as const;
const light = {
primary: { main: '#8b3fd6', light: '#a86ee2', dark: '#6a24ad', contrastText: '#ffffff' },
secondary: { main: '#e0457b', light: '#ec6f9a', dark: '#b12a5c', contrastText: '#ffffff' },
success: { main: '#1f9d6b' },
warning: { main: '#c07a10' },
error: { main: '#d13b4d' },
info: { main: '#2b7fb8' },
background: { default: '#faf7fd', paper: '#ffffff' },
text: { primary: '#221933', secondary: '#6a6280' },
divider: '#ece6f4',
};
const dark = {
primary: { main: '#b98cf0', light: '#cdaef5', dark: '#8b5fd0', contrastText: '#1a1024' },
secondary: { main: '#f47ba6', light: '#f89dbd', dark: '#c85583', contrastText: '#1a1024' },
success: { main: '#4cc794' },
warning: { main: '#e0a94a' },
error: { main: '#f16274' },
info: { main: '#5aa9dd' },
background: { default: '#140f1c', paper: '#1e1830' },
text: { primary: '#f4f0fb', secondary: '#b3aac6' },
divider: '#302743',
};
export function makeTheme(mode: Mode): Theme {
const palette = mode === 'light' ? light : dark;
return createTheme({
...shared,
palette: { mode, ...palette },
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: { borderRadius: 999, paddingLeft: 22, paddingRight: 22 },
sizeLarge: { paddingTop: 12, paddingBottom: 12, fontSize: '1rem' },
},
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: { root: { backgroundImage: 'none' } },
},
MuiCard: {
defaultProps: { variant: 'outlined' },
styleOverrides: {
root: {
borderColor: palette.divider,
transition: 'transform .25s ease, box-shadow .25s ease, border-color .25s ease',
},
},
},
MuiTextField: { defaultProps: { size: 'medium' } },
MuiAppBar: { defaultProps: { elevation: 0, color: 'transparent' } },
MuiChip: { styleOverrides: { root: { fontWeight: 600 } } },
},
});
}
// Reusable gradient string for hero / accents, mode-aware.
export const brandGradient = (mode: Mode) =>
mode === 'light'
? 'linear-gradient(135deg, #8b3fd6 0%, #e0457b 100%)'
: 'linear-gradient(135deg, #b98cf0 0%, #f47ba6 100%)';
+51
View File
@@ -0,0 +1,51 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import { makeTheme, type Mode } from '../theme';
type Ctx = { mode: Mode; toggle: () => void };
const ColorModeContext = createContext<Ctx>({ mode: 'light', toggle: () => {} });
const STORAGE_KEY = 'swatiyadav-theme';
function initialMode(): Mode {
if (typeof window === 'undefined') return 'light';
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === 'light' || saved === 'dark') return saved;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ColorModeProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<Mode>(initialMode);
const ctx = useMemo<Ctx>(
() => ({
mode,
toggle: () =>
setMode((m) => {
const next = m === 'light' ? 'dark' : 'light';
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
/* ignore */
}
return next;
}),
}),
[mode],
);
const theme = useMemo(() => makeTheme(mode), [mode]);
return (
<ColorModeContext.Provider value={ctx}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ColorModeContext.Provider>
);
}
export const useColorMode = () => useContext(ColorModeContext);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+25
View File
@@ -0,0 +1,25 @@
// DevVibeCoder Design-mode inspector: a Babel visitor that stamps every JSX
// element with `data-vc-loc="src/App.tsx:41:7"` (path relative to this
// folder, 1-based line and column). vite.config.ts hands it to
// @vitejs/plugin-react only for `vite dev`, so a build carries no stamps.
// The Design pane maps a click in the preview back to that source position.
/* eslint-disable @typescript-eslint/no-explicit-any */
import path from 'node:path';
export const vcBabelLoc = () => ({
name: 'vc-loc',
visitor: {
JSXOpeningElement(p: any, state: any) {
const loc = p.node.loc;
const file: string | undefined = state.filename || state.file?.opts?.filename;
if (!loc || !file) return;
if (p.node.attributes.some((a: any) => a.type === 'JSXAttribute' && a.name?.name === 'data-vc-loc')) return;
const rel = path.relative(state.cwd || process.cwd(), file).split(path.sep).join('/');
p.node.attributes.push({
type: 'JSXAttribute',
name: { type: 'JSXIdentifier', name: 'data-vc-loc' },
value: { type: 'StringLiteral', value: `${rel}:${loc.start.line}:${loc.start.column + 1}` },
});
},
},
});
+257
View File
@@ -0,0 +1,257 @@
// DevVibeCoder Design-mode runtime. Dev only: main.* imports it behind
// `import.meta.env.DEV`, so it never reaches a production bundle.
//
// It never touches the source. It answers the host's handshake, reports which
// stamped element (`data-vc-loc`, see inspector.ts) is under the pointer, was
// clicked, or was dragged where, and keeps the host supplied with element
// rectangles so the host can draw outlines itself. Messages only flow to and
// from the ONE origin that said hello first.
const V = 1;
type Rect = { x: number; y: number; width: number; height: number };
type Node = { id: string; loc: string; tag: string; children: Node[] };
type Position = 'before' | 'after' | 'into';
let parentOrigin: string | null = null;
let active = false;
let raf = 0;
const post = (msg: Record<string, unknown>) => {
if (parentOrigin) window.parent.postMessage({ vc: V, ...msg }, parentOrigin);
};
const plain = (r: DOMRectReadOnly): Rect => ({ x: r.x, y: r.y, width: r.width, height: r.height });
const stamped = (): HTMLElement[] => Array.from(document.querySelectorAll<HTMLElement>('[data-vc-loc]'));
// The same source element renders many times inside a list, so the identity
// the host uses is `loc#n`: the nth stamped element in document order with
// that loc. Recomputed on every report, never stored on the element.
const idsOf = (all: HTMLElement[]): Map<HTMLElement, string> => {
const seen = new Map<string, number>();
const ids = new Map<HTMLElement, string>();
for (const el of all) {
const loc = el.dataset.vcLoc || '';
const n = seen.get(loc) ?? 0;
seen.set(loc, n + 1);
ids.set(el, `${loc}#${n}`);
}
return ids;
};
const idOf = (el: HTMLElement): string => idsOf(stamped()).get(el) || '';
// Stamped children of `el`, skipping unstamped wrappers in between.
const stampedChildren = (el: Element): HTMLElement[] => {
const out: HTMLElement[] = [];
const walk = (n: Element) => {
for (const c of Array.from(n.children)) {
if ((c as HTMLElement).dataset?.vcLoc !== undefined) out.push(c as HTMLElement);
else walk(c);
}
};
walk(el);
return out;
};
const tree = (): Node[] => {
const all = stamped();
const ids = idsOf(all);
const build = (el: HTMLElement): Node => ({
id: ids.get(el) || '',
loc: el.dataset.vcLoc || '',
tag: el.tagName.toLowerCase(),
children: stampedChildren(el).map(build),
});
return all.filter((el) => !el.parentElement?.closest('[data-vc-loc]')).map(build);
};
const sendRects = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const rects: Record<string, Rect> = {};
const all = stamped();
const ids = idsOf(all);
for (const el of all) rects[ids.get(el) || ''] = plain(el.getBoundingClientRect());
post({ type: 'rects', rects });
});
};
const sendTree = () => post({ type: 'tree', tree: tree(), route: location.pathname });
const closestStamped = (n: Element | null): HTMLElement | null =>
(n?.closest?.('[data-vc-loc]') as HTMLElement | null) ?? null;
const target = (e: Event): HTMLElement | null => closestStamped(e.target as Element | null);
// --- drag ---------------------------------------------------------------------
// Pointer-driven, no HTML5 DnD: it works on every element, including ones with
// their own click handlers, and never starts the app's own drag behaviour.
let press: { el: HTMLElement; x: number; y: number } | null = null;
let dragging: HTMLElement | null = null;
let over: { el: HTMLElement; position: Position } | null = null;
const isRow = (el: HTMLElement): boolean => {
const p = el.parentElement;
if (!p) return false;
const cs = getComputedStyle(p);
if (cs.display.includes('flex')) return cs.flexDirection.startsWith('row');
if (cs.display.includes('grid')) return true;
return cs.display === 'inline' || getComputedStyle(el).display.startsWith('inline');
};
const dropAt = (x: number, y: number): { el: HTMLElement; position: Position } | null => {
const hit = closestStamped(document.elementFromPoint(x, y));
if (!hit || !dragging || hit === dragging || dragging.contains(hit)) return null;
const r = hit.getBoundingClientRect();
if (!stampedChildren(hit).length && !hit.textContent?.trim()) {
// An empty container: the middle band drops inside it.
const fx = (x - r.x) / r.width;
const fy = (y - r.y) / r.height;
if (fx > 0.25 && fx < 0.75 && fy > 0.25 && fy < 0.75) return { el: hit, position: 'into' };
}
const half = isRow(hit) ? x < r.x + r.width / 2 : y < r.y + r.height / 2;
return { el: hit, position: half ? 'before' : 'after' };
};
const endDrag = (commit: boolean) => {
if (dragging) {
if (commit && over) post({ type: 'drop', id: idOf(dragging), targetId: idOf(over.el), position: over.position });
else post({ type: 'dragover', targetId: null });
}
press = null;
dragging = null;
over = null;
document.documentElement.style.cursor = active ? 'crosshair' : '';
};
const onDown = (e: PointerEvent) => {
if (e.button !== 0) return;
const el = target(e);
press = el ? { el, x: e.clientX, y: e.clientY } : null;
};
const onMove = (e: PointerEvent) => {
if (press && !dragging && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 4) {
dragging = press.el;
document.documentElement.style.cursor = 'grabbing';
post({ type: 'dragstart', id: idOf(dragging) });
}
if (dragging) {
e.preventDefault();
const next = dropAt(e.clientX, e.clientY);
if (next?.el !== over?.el || next?.position !== over?.position) {
over = next;
post({
type: 'dragover',
targetId: over ? idOf(over.el) : null,
position: over?.position ?? null,
rect: over ? plain(over.el.getBoundingClientRect()) : null,
row: over ? isRow(over.el) : false,
});
}
return;
}
const el = target(e);
post({ type: 'hover', id: el ? idOf(el) : null, rect: el ? plain(el.getBoundingClientRect()) : null });
};
const onUp = () => {
if (dragging) endDrag(true);
press = null;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && dragging) endDrag(false);
};
const onClick = (e: MouseEvent) => {
// Design mode: a click selects, it does not navigate or submit.
e.preventDefault();
e.stopPropagation();
const el = target(e);
if (!el) {
post({ type: 'select', id: null });
return;
}
post({
type: 'select',
id: idOf(el),
loc: el.dataset.vcLoc,
tag: el.tagName.toLowerCase(),
rect: plain(el.getBoundingClientRect()),
});
};
const observer = new MutationObserver(() => {
sendRects();
sendTree();
});
const setActive = (on: boolean) => {
if (on === active) return;
active = on;
const opts = { capture: true } as const;
if (on) {
document.addEventListener('pointerdown', onDown, opts);
document.addEventListener('pointermove', onMove, opts);
document.addEventListener('pointerup', onUp, opts);
document.addEventListener('keydown', onKey, opts);
document.addEventListener('click', onClick, opts);
window.addEventListener('scroll', sendRects, opts);
window.addEventListener('resize', sendRects);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
document.documentElement.style.cursor = 'crosshair';
sendTree();
sendRects();
} else {
endDrag(false);
document.removeEventListener('pointerdown', onDown, opts);
document.removeEventListener('pointermove', onMove, opts);
document.removeEventListener('pointerup', onUp, opts);
document.removeEventListener('keydown', onKey, opts);
document.removeEventListener('click', onClick, opts);
window.removeEventListener('scroll', sendRects, opts);
window.removeEventListener('resize', sendRects);
observer.disconnect();
document.documentElement.style.cursor = '';
}
};
// Route changes: the host's page picker and the Layers tree follow them.
for (const m of ['pushState', 'replaceState'] as const) {
const orig = history[m];
history[m] = function (this: History, ...args: Parameters<History['pushState']>) {
const r = orig.apply(this, args);
queueMicrotask(sendTree);
return r;
};
}
window.addEventListener('popstate', () => queueMicrotask(sendTree));
window.addEventListener('message', (e: MessageEvent) => {
const d = e.data;
if (!d || d.vc !== V || window.parent === window) return;
if (d.type === 'hello') {
// The first hello pins the host; any other origin is ignored for the
// life of the page.
if (parentOrigin && e.origin !== parentOrigin) return;
parentOrigin = e.origin;
post({ type: 'ready', route: location.pathname });
setActive(d.mode === 'design');
return;
}
if (e.origin !== parentOrigin) return;
if (d.type === 'mode') setActive(d.mode === 'design');
else if (d.type === 'navigate' && typeof d.path === 'string') {
history.pushState(null, '', d.path);
dispatchEvent(new PopStateEvent('popstate'));
} else if (d.type === 'refresh') {
sendTree();
sendRects();
}
});
export {};
+19
View File
@@ -0,0 +1,19 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { vcBabelLoc } from './vc/inspector';
// Deploy owns `base` and the /api proxy. Changing them here breaks the deployed
// app while dev keeps working — the worst kind of change to make.
// `vc/` is DevVibeCoder's Design mode: source-location stamps, dev server only.
export default defineConfig(({ command }) => ({
plugins: [react(command === 'serve' ? { babel: { plugins: [vcBabelLoc] } } : {})],
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true,
},
},
},
}));