Merge branch 'auth'

This commit is contained in:
chark1es 2025-03-08 02:36:27 -08:00
commit eb5c994f2d
76 changed files with 22438 additions and 9522 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ dist/
# generated types # generated types
.astro/ .astro/
.cursor
# dependencies # dependencies
node_modules/ node_modules/

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"workbench.colorCustomizations": {
"activityBar.background": "#221489",
"titleBar.activeBackground": "#301DC0",
"titleBar.activeForeground": "#F9F9FE"
}
}

View file

@ -15,9 +15,9 @@ import icon from "astro-icon";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()], integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),
}); });

273
bun.lock
View file

@ -5,21 +5,40 @@
"dependencies": { "dependencies": {
"@astrojs/mdx": "4.0.3", "@astrojs/mdx": "4.0.3",
"@astrojs/node": "^9.0.0", "@astrojs/node": "^9.0.0",
"@astrojs/react": "4.1.2", "@astrojs/react": "^4.2.0",
"@astrojs/tailwind": "5.1.4", "@astrojs/tailwind": "5.1.4",
"@types/react": "^18.3.14", "@iconify-json/heroicons": "^1.2.2",
"@types/react-dom": "^18.3.2", "@iconify/react": "^5.2.0",
"@types/highlight.js": "^10.1.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.15",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"astro": "5.1.1", "astro": "5.1.1",
"astro-expressive-code": "^0.38.3", "astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.4", "astro-icon": "^1.1.5",
"chart.js": "^4.4.7",
"dexie": "^4.0.11",
"framer-motion": "^12.4.4",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.2", "next": "^15.1.2",
"react": "^18.3.1", "pocketbase": "^0.25.1",
"react-dom": "^18.3.1", "prismjs": "^1.29.0",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"rehype-expressive-code": "^0.40.2",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
}, },
"devDependencies": { "devDependencies": {
"@types/prismjs": "^1.26.5",
"daisyui": "^4.12.23",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"tailwindcss-animated": "^1.1.2", "tailwindcss-animated": "^1.1.2",
@ -48,7 +67,7 @@
"@astrojs/prism": ["@astrojs/prism@3.2.0", "", { "dependencies": { "prismjs": "^1.29.0" } }, "sha512-GilTHKGCW6HMq7y3BUv9Ac7GMe/MO9gi9GW62GzKtth0SwukCu/qp2wLiGpEujhY+VVhaG9v7kv/5vFzvf4NYw=="], "@astrojs/prism": ["@astrojs/prism@3.2.0", "", { "dependencies": { "prismjs": "^1.29.0" } }, "sha512-GilTHKGCW6HMq7y3BUv9Ac7GMe/MO9gi9GW62GzKtth0SwukCu/qp2wLiGpEujhY+VVhaG9v7kv/5vFzvf4NYw=="],
"@astrojs/react": ["@astrojs/react@4.1.2", "", { "dependencies": { "@vitejs/plugin-react": "^4.3.4", "ultrahtml": "^1.5.3", "vite": "^6.0.5" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-Slw8Bho50w1+rYnSnDl5PDAUikSOEItx5DEJU5OgmarTirBr1audIb2DgC8faAGcGkq5WhuUVsSiq/TmSORlwA=="], "@astrojs/react": ["@astrojs/react@4.2.0", "", { "dependencies": { "@vitejs/plugin-react": "^4.3.4", "ultrahtml": "^1.5.3", "vite": "^6.0.9" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-2OccnYFK+mLuy9GpJqPM3BQGvvemnXNeww+nBVYFuiH04L7YIdfg4Gq0LT7v/BraiuADV5uTl9VhTDL/ZQPAhw=="],
"@astrojs/tailwind": ["@astrojs/tailwind@5.1.4", "", { "dependencies": { "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "postcss-load-config": "^4.0.2" }, "peerDependencies": { "astro": "^3.0.0 || ^4.0.0 || ^5.0.0", "tailwindcss": "^3.0.24" } }, "sha512-EJ3uoTZZr0RYwTrVS2HgYN0+VbXvg7h87AtwpD5OzqS3GyMwRmzfOwHfORTxoWGQRrY9k/Fi+Awk60kwpvRL5Q=="], "@astrojs/tailwind": ["@astrojs/tailwind@5.1.4", "", { "dependencies": { "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "postcss-load-config": "^4.0.2" }, "peerDependencies": { "astro": "^3.0.0 || ^4.0.0 || ^5.0.0", "tailwindcss": "^3.0.24" } }, "sha512-EJ3uoTZZr0RYwTrVS2HgYN0+VbXvg7h87AtwpD5OzqS3GyMwRmzfOwHfORTxoWGQRrY9k/Fi+Awk60kwpvRL5Q=="],
@ -128,9 +147,11 @@
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg=="], "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
@ -142,13 +163,17 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@expressive-code/core": ["@expressive-code/core@0.38.3", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-s0/OtdRpBONwcn23O8nVwDNQqpBGKscysejkeBkwlIeHRLZWgiTVrusT5Idrdz1d8cW5wRk9iGsAIQmwDPXgJg=="], "@expressive-code/core": ["@expressive-code/core@0.40.2", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-gXY3v7jbgz6nWKvRpoDxK4AHUPkZRuJsM79vHX/5uhV9/qX6Qnctp/U/dMHog/LCVXcuOps+5nRmf1uxQVPb3w=="],
"@expressive-code/plugin-frames": ["@expressive-code/plugin-frames@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3" } }, "sha512-qL2oC6FplmHNQfZ8ZkTR64/wKo9x0c8uP2WDftR/ydwN/yhe1ed7ZWYb8r3dezxsls+tDokCnN4zYR594jbpvg=="], "@expressive-code/plugin-frames": ["@expressive-code/plugin-frames@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2" } }, "sha512-aLw5IlDlZWb10Jo/TTDCVsmJhKfZ7FJI83Zo9VDrV0OBlmHAg7klZqw68VDz7FlftIBVAmMby53/MNXPnMjTSQ=="],
"@expressive-code/plugin-shiki": ["@expressive-code/plugin-shiki@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3", "shiki": "^1.22.2" } }, "sha512-kqHnglZeesqG3UKrb6e9Fq5W36AZ05Y9tCREmSN2lw8LVTqENIeCIkLDdWtQ5VoHlKqwUEQFTVlRehdwoY7Gmw=="], "@expressive-code/plugin-shiki": ["@expressive-code/plugin-shiki@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2", "shiki": "^1.26.1" } }, "sha512-t2HMR5BO6GdDW1c1ISBTk66xO503e/Z8ecZdNcr6E4NpUfvY+MRje+LtrcvbBqMwWBBO8RpVKcam/Uy+1GxwKQ=="],
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3" } }, "sha512-dPK3+BVGTbTmGQGU3Fkj3jZ3OltWUAlxetMHI6limUGCWBCucZiwoZeFM/WmqQa71GyKRzhBT+iEov6kkz2xVA=="], "@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2" } }, "sha512-/XoLjD67K9nfM4TgDlXAExzMJp6ewFKxNpfUw4F7q5Ecy+IU3/9zQQG/O70Zy+RxYTwKGw2MA9kd7yelsxnSmw=="],
"@iconify-json/heroicons": ["@iconify-json/heroicons@1.2.2", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-qoW4pXr5kTTL6juEjgTs83OJIwpePu7q1tdtKVEdj+i0zyyVHgg/dd9grsXJQnpTpBt6/VwNjrXBvFjRsKPENg=="],
"@iconify/react": ["@iconify/react@5.2.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-7Sdjrqq3fkkQNks9SY3adGC37NQTHsBJL2PRKlQd455PoDi9s+Es9AUTY+vGLFOYs5yO9w9yCE42pmxCwG26WA=="],
"@iconify/tools": ["@iconify/tools@4.0.7", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.32", "@types/tar": "^6.1.13", "axios": "^1.7.7", "cheerio": "1.0.0", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^0.5.0", "pathe": "^1.1.2", "svgo": "^3.3.2", "tar": "^6.2.1" } }, "sha512-zOJxKIfZn96ZRGGvIWzDRLD9vb2CsxjcLuM+QIdvwWbv6SWhm49gECzUnd4d2P0sq9sfodT7yCNobWK8nvavxQ=="], "@iconify/tools": ["@iconify/tools@4.0.7", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.32", "@types/tar": "^6.1.13", "axios": "^1.7.7", "cheerio": "1.0.0", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^0.5.0", "pathe": "^1.1.2", "svgo": "^3.3.2", "tar": "^6.2.1" } }, "sha512-zOJxKIfZn96ZRGGvIWzDRLD9vb2CsxjcLuM+QIdvwWbv6SWhm49gECzUnd4d2P0sq9sfodT7yCNobWK8nvavxQ=="],
@ -206,6 +231,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="],
"@next/env": ["@next/env@15.1.3", "", {}, "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="], "@next/env": ["@next/env@15.1.3", "", {}, "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="],
@ -310,6 +337,10 @@
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.24.0", "", { "dependencies": { "@shikijs/types": "1.24.0", "@shikijs/vscode-textmate": "^9.3.0" } }, "sha512-Eua0qNOL73Y82lGA4GF5P+G2+VXX9XnuUxkiUuwcxQPH4wom+tE39kZpBFXfUuwNYxHSkrSxpB1p4kyRW0moSg=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.24.0", "", { "dependencies": { "@shikijs/types": "1.24.0", "@shikijs/vscode-textmate": "^9.3.0" } }, "sha512-Eua0qNOL73Y82lGA4GF5P+G2+VXX9XnuUxkiUuwcxQPH4wom+tE39kZpBFXfUuwNYxHSkrSxpB1p4kyRW0moSg=="],
"@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="],
"@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="],
"@shikijs/types": ["@shikijs/types@1.24.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^9.3.0", "@types/hast": "^3.0.4" } }, "sha512-aptbEuq1Pk88DMlCe+FzXNnBZ17LCiLIGWAeCWhoFDzia5Q5Krx3DgnULLiouSdd6+LUM39XwXGppqYE0Ghtug=="], "@shikijs/types": ["@shikijs/types@1.24.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^9.3.0", "@types/hast": "^3.0.4" } }, "sha512-aptbEuq1Pk88DMlCe+FzXNnBZ17LCiLIGWAeCWhoFDzia5Q5Krx3DgnULLiouSdd6+LUM39XwXGppqYE0Ghtug=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@9.3.0", "", {}, "sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@9.3.0", "", {}, "sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA=="],
@ -340,6 +371,12 @@
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/highlight.js": ["@types/highlight.js@10.1.0", "", { "dependencies": { "highlight.js": "*" } }, "sha512-77hF2dGBsOgnvZll1vymYiNUtqJ8cJfXPD6GG/2M0aLRc29PkvB7Au6sIDjIEFcSICBhCh2+Pyq6WSRS7LUm6A=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/lodash": ["@types/lodash@4.17.15", "", {}, "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
@ -350,11 +387,11 @@
"@types/node": ["@types/node@22.10.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ=="], "@types/node": ["@types/node@22.10.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ=="],
"@types/prop-types": ["@types/prop-types@15.7.13", "", {}, "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="], "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
"@types/react": ["@types/react@18.3.14", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg=="], "@types/react": ["@types/react@19.0.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw=="],
"@types/react-dom": ["@types/react-dom@18.3.2", "", { "dependencies": { "@types/react": "^18" } }, "sha512-Fqp+rcvem9wEnGr3RY8dYNvSQ8PoLqjZ9HLgaPUOjJJD120uDyOxOjc/39M4Kddp9JQCxpGQbnhVQF0C0ncYVg=="], "@types/react-dom": ["@types/react-dom@19.0.3", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA=="],
"@types/tar": ["@types/tar@6.1.13", "", { "dependencies": { "@types/node": "*", "minipass": "^4.0.0" } }, "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw=="], "@types/tar": ["@types/tar@6.1.13", "", { "dependencies": { "@types/node": "*", "minipass": "^4.0.0" } }, "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw=="],
@ -392,9 +429,9 @@
"astro": ["astro@5.1.1", "", { "dependencies": { "@astrojs/compiler": "^2.10.3", "@astrojs/internal-helpers": "0.4.2", "@astrojs/markdown-remark": "6.0.1", "@astrojs/telemetry": "3.2.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.3", "@types/cookie": "^0.6.0", "acorn": "^8.14.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.1.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^0.7.2", "cssesc": "^3.0.0", "debug": "^4.3.7", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.5.4", "esbuild": "^0.21.5", "estree-walker": "^3.0.3", "fast-glob": "^3.3.2", "flattie": "^1.1.1", "github-slugger": "^2.0.0", "html-escaper": "^3.0.3", "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.14", "magicast": "^0.3.5", "micromatch": "^4.0.8", "mrmime": "^2.0.0", "neotraverse": "^0.6.18", "p-limit": "^6.1.0", "p-queue": "^8.0.1", "preferred-pm": "^4.0.0", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.6.3", "shiki": "^1.23.1", "tinyexec": "^0.3.1", "tsconfck": "^3.1.4", "ultrahtml": "^1.5.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.14.0", "vfile": "^6.0.3", "vite": "^6.0.5", "vitefu": "^1.0.4", "which-pm": "^3.0.0", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.1.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-prpWC2PRs4P3FKQg6gZaU+VNMqbZi5pDvORGB2nrjfRjkrvF6/l4BqhvkJ6YQ0Ohm5rIMVz8ljgaRI77mLHbwg=="], "astro": ["astro@5.1.1", "", { "dependencies": { "@astrojs/compiler": "^2.10.3", "@astrojs/internal-helpers": "0.4.2", "@astrojs/markdown-remark": "6.0.1", "@astrojs/telemetry": "3.2.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.3", "@types/cookie": "^0.6.0", "acorn": "^8.14.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.1.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^0.7.2", "cssesc": "^3.0.0", "debug": "^4.3.7", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.5.4", "esbuild": "^0.21.5", "estree-walker": "^3.0.3", "fast-glob": "^3.3.2", "flattie": "^1.1.1", "github-slugger": "^2.0.0", "html-escaper": "^3.0.3", "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.14", "magicast": "^0.3.5", "micromatch": "^4.0.8", "mrmime": "^2.0.0", "neotraverse": "^0.6.18", "p-limit": "^6.1.0", "p-queue": "^8.0.1", "preferred-pm": "^4.0.0", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.6.3", "shiki": "^1.23.1", "tinyexec": "^0.3.1", "tsconfck": "^3.1.4", "ultrahtml": "^1.5.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.14.0", "vfile": "^6.0.3", "vite": "^6.0.5", "vitefu": "^1.0.4", "which-pm": "^3.0.0", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.1.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-prpWC2PRs4P3FKQg6gZaU+VNMqbZi5pDvORGB2nrjfRjkrvF6/l4BqhvkJ6YQ0Ohm5rIMVz8ljgaRI77mLHbwg=="],
"astro-expressive-code": ["astro-expressive-code@0.38.3", "", { "dependencies": { "rehype-expressive-code": "^0.38.3" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-Tvdc7RV0G92BbtyEOsfJtXU35w41CkM94fOAzxbQP67Wj5jArfserJ321FO4XA7WG9QMV0GIBmQq77NBIRDzpQ=="], "astro-expressive-code": ["astro-expressive-code@0.40.2", "", { "dependencies": { "rehype-expressive-code": "^0.40.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-yJMQId0yXSAbW9I6yqvJ3FcjKzJ8zRL7elbJbllkv1ZJPlsI0NI83Pxn1YL1IapEM347EvOOkSW2GL+2+NO61w=="],
"astro-icon": ["astro-icon@1.1.4", "", { "dependencies": { "@iconify/tools": "^4.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.30" } }, "sha512-sMLkQaevIQLv38WBzb/RDbsmxhg5+X+KcNmpTi9cE6MLurNWU1MPnlO87d9Vwg4HxTenKpDlYp81A3syXFW/gw=="], "astro-icon": ["astro-icon@1.1.5", "", { "dependencies": { "@iconify/tools": "^4.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^2.1.30" } }, "sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@ -446,6 +483,8 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chart.js": ["chart.js@4.4.7", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw=="],
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="], "cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
@ -494,6 +533,8 @@
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crossws": ["crossws@0.3.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw=="], "crossws": ["crossws@0.3.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw=="],
@ -502,6 +543,8 @@
"css-selector-parser": ["css-selector-parser@3.0.5", "", {}, "sha512-3itoDFbKUNx1eKmVpYMFyqKX04Ww9osZ+dLgrk6GEv6KMVeXUhUnp4I5X+evw+u3ZxVU6RFXSSRxlTeMh8bA+g=="], "css-selector-parser": ["css-selector-parser@3.0.5", "", {}, "sha512-3itoDFbKUNx1eKmVpYMFyqKX04Ww9osZ+dLgrk6GEv6KMVeXUhUnp4I5X+evw+u3ZxVU6RFXSSRxlTeMh8bA+g=="],
"css-selector-tokenizer": ["css-selector-tokenizer@0.8.0", "", { "dependencies": { "cssesc": "^3.0.0", "fastparse": "^1.1.2" } }, "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg=="],
"css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
"css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="], "css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="],
@ -512,6 +555,10 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"culori": ["culori@3.3.0", "", {}, "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ=="],
"daisyui": ["daisyui@4.12.23", "", { "dependencies": { "css-selector-tokenizer": "^0.8", "culori": "^3", "picocolors": "^1", "postcss-js": "^4" } }, "sha512-EM38duvxutJ5PD65lO/AFMpcw+9qEy6XAZrTpzp7WyaPeO/l+F/Qiq0ECHHmFNcFXh5aVoALY4MGrrxtCiaQCQ=="],
"debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="],
@ -536,6 +583,8 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dexie": ["dexie@4.0.11", "", {}, "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="], "diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],
@ -608,7 +657,7 @@
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"expressive-code": ["expressive-code@0.38.3", "", { "dependencies": { "@expressive-code/core": "^0.38.3", "@expressive-code/plugin-frames": "^0.38.3", "@expressive-code/plugin-shiki": "^0.38.3", "@expressive-code/plugin-text-markers": "^0.38.3" } }, "sha512-COM04AiUotHCKJgWdn7NtW2lqu8OW8owAidMpkXt1qxrZ9Q2iC7+tok/1qIn2ocGnczvr9paIySgGnEwFeEQ8Q=="], "expressive-code": ["expressive-code@0.40.2", "", { "dependencies": { "@expressive-code/core": "^0.40.2", "@expressive-code/plugin-frames": "^0.40.2", "@expressive-code/plugin-shiki": "^0.40.2", "@expressive-code/plugin-text-markers": "^0.40.2" } }, "sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
@ -616,6 +665,8 @@
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="], "fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
"fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="],
"fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="], "fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
@ -638,7 +689,7 @@
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"framer-motion": ["framer-motion@11.15.0", "", { "dependencies": { "motion-dom": "^11.14.3", "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w=="], "framer-motion": ["framer-motion@12.4.4", "", { "dependencies": { "motion-dom": "^12.4.4", "motion-utils": "^12.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JWkVwbJBgVkeZHNcnk8ififgwTF+5de9wbJnTLI+g9YqaGo75Xd5uRVDm9FR8chqRDOKcXv/71f40CGescYVmg=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
@ -664,6 +715,8 @@
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"goober": ["goober@2.1.16", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"h3": ["h3@1.13.0", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": ">=0.2.0 <0.4.0", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "ohash": "^1.1.4", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3", "unenv": "^1.10.0" } }, "sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg=="], "h3": ["h3@1.13.0", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": ">=0.2.0 <0.4.0", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "ohash": "^1.1.4", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3", "unenv": "^1.10.0" } }, "sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg=="],
@ -700,6 +753,8 @@
"hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="], "hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
@ -716,6 +771,8 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="], "import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@ -758,6 +815,8 @@
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
@ -772,10 +831,14 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@ -788,9 +851,9 @@
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@ -934,9 +997,9 @@
"motion": ["motion@11.15.0", "", { "dependencies": { "framer-motion": "^11.15.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw=="], "motion": ["motion@11.15.0", "", { "dependencies": { "framer-motion": "^11.15.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw=="],
"motion-dom": ["motion-dom@11.14.3", "", {}, "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="], "motion-dom": ["motion-dom@12.4.4", "", { "dependencies": { "motion-utils": "^12.0.0" } }, "sha512-D8Kjp8oqUNqxoAVmIlOH+YCMov/4koBAmG4OJs0VWfh18xkQEIsx9+S7yrXyx0XaMBEPtre6e9LiSW2Zs7vIhA=="],
"motion-utils": ["motion-utils@11.14.3", "", {}, "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="], "motion-utils": ["motion-utils@12.0.0", "", {}, "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA=="],
"mrmime": ["mrmime@2.0.0", "", {}, "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="], "mrmime": ["mrmime@2.0.0", "", {}, "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="],
@ -1000,6 +1063,8 @@
"package-manager-detector": ["package-manager-detector@0.2.6", "", {}, "sha512-9vPH3qooBlYRJdmdYP00nvjZOulm40r5dhtal8st18ctf+6S1k7pi5yIHLvI4w5D70x0Y+xdVD9qITH0QO/A8A=="], "package-manager-detector": ["package-manager-detector@0.2.6", "", {}, "sha512-9vPH3qooBlYRJdmdYP00nvjZOulm40r5dhtal8st18ctf+6S1k7pi5yIHLvI4w5D70x0Y+xdVD9qITH0QO/A8A=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.1", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w=="], "parse-entities": ["parse-entities@4.0.1", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w=="],
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
@ -1034,6 +1099,8 @@
"pkg-types": ["pkg-types@1.2.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.2", "pathe": "^1.1.2" } }, "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw=="], "pkg-types": ["pkg-types@1.2.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.2", "pathe": "^1.1.2" } }, "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw=="],
"pocketbase": ["pocketbase@0.25.1", "", {}, "sha512-2IH0KLI/qMNR/E17C7BGWX2FxW7Tead+igLHOWZ45P56v/NyVT18Jnmddeft+3qWWGL1Hog2F8bc4orWV/+Fcg=="],
"postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@ -1056,6 +1123,8 @@
"prismjs": ["prismjs@1.29.0", "", {}, "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="], "prismjs": ["prismjs@1.29.0", "", {}, "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
@ -1070,9 +1139,13 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="],
"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-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="],
"react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="],
"react-hot-toast": ["react-hot-toast@2.5.2", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw=="],
"react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="], "react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="],
@ -1080,6 +1153,8 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
@ -1098,7 +1173,7 @@
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
"rehype-expressive-code": ["rehype-expressive-code@0.38.3", "", { "dependencies": { "expressive-code": "^0.38.3" } }, "sha512-RYSSDkMBikoTbycZPkcWp6ELneANT4eTpND1DSRJ6nI2eVFUwTBDCvE2vO6jOOTaavwnPiydi4i/87NRyjpdOA=="], "rehype-expressive-code": ["rehype-expressive-code@0.40.2", "", { "dependencies": { "expressive-code": "^0.40.2" } }, "sha512-+kn+AMGCrGzvtH8Q5lC6Y5lnmTV/r33fdmi5QU/IH1KPHKobKr5UnLwJuqHv5jBTSN/0v2wLDS7RTM73FVzqmQ=="],
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
@ -1138,11 +1213,13 @@
"s.color": ["s.color@0.0.15", "", {}, "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA=="], "s.color": ["s.color@0.0.15", "", {}, "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
"semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
@ -1150,6 +1227,8 @@
"server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="], "server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
@ -1184,6 +1263,8 @@
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
@ -1290,7 +1371,7 @@
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
"vite": ["vite@6.0.5", "", { "dependencies": { "esbuild": "0.24.0", "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": { "vite": "bin/vite.js" } }, "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g=="], "vite": ["vite@6.0.11", "", { "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": { "vite": "bin/vite.js" } }, "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg=="],
"vitefu": ["vitefu@1.0.4", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow=="], "vitefu": ["vitefu@1.0.4", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow=="],
@ -1344,6 +1425,8 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@expressive-code/plugin-shiki/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
@ -1352,10 +1435,16 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"astro/vite": ["vite@6.0.5", "", { "dependencies": { "esbuild": "0.24.0", "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": { "vite": "bin/vite.js" } }, "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
@ -1378,6 +1467,8 @@
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"motion/framer-motion": ["framer-motion@11.15.0", "", { "dependencies": { "motion-dom": "^11.14.3", "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@ -1404,7 +1495,7 @@
"tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
"vite/esbuild": ["esbuild@0.24.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.0", "@esbuild/android-arm": "0.24.0", "@esbuild/android-arm64": "0.24.0", "@esbuild/android-x64": "0.24.0", "@esbuild/darwin-arm64": "0.24.0", "@esbuild/darwin-x64": "0.24.0", "@esbuild/freebsd-arm64": "0.24.0", "@esbuild/freebsd-x64": "0.24.0", "@esbuild/linux-arm": "0.24.0", "@esbuild/linux-arm64": "0.24.0", "@esbuild/linux-ia32": "0.24.0", "@esbuild/linux-loong64": "0.24.0", "@esbuild/linux-mips64el": "0.24.0", "@esbuild/linux-ppc64": "0.24.0", "@esbuild/linux-riscv64": "0.24.0", "@esbuild/linux-s390x": "0.24.0", "@esbuild/linux-x64": "0.24.0", "@esbuild/netbsd-x64": "0.24.0", "@esbuild/openbsd-arm64": "0.24.0", "@esbuild/openbsd-x64": "0.24.0", "@esbuild/sunos-x64": "0.24.0", "@esbuild/win32-arm64": "0.24.0", "@esbuild/win32-ia32": "0.24.0", "@esbuild/win32-x64": "0.24.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ=="], "vite/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": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@ -1414,70 +1505,146 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@shikijs/langs/@shikijs/types/@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="],
"@shikijs/themes/@shikijs/types/@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.1", "", {}, "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"astro/vite/esbuild": ["esbuild@0.24.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.0", "@esbuild/android-arm": "0.24.0", "@esbuild/android-arm64": "0.24.0", "@esbuild/android-x64": "0.24.0", "@esbuild/darwin-arm64": "0.24.0", "@esbuild/darwin-x64": "0.24.0", "@esbuild/freebsd-arm64": "0.24.0", "@esbuild/freebsd-x64": "0.24.0", "@esbuild/linux-arm": "0.24.0", "@esbuild/linux-arm64": "0.24.0", "@esbuild/linux-ia32": "0.24.0", "@esbuild/linux-loong64": "0.24.0", "@esbuild/linux-mips64el": "0.24.0", "@esbuild/linux-ppc64": "0.24.0", "@esbuild/linux-riscv64": "0.24.0", "@esbuild/linux-s390x": "0.24.0", "@esbuild/linux-x64": "0.24.0", "@esbuild/netbsd-x64": "0.24.0", "@esbuild/openbsd-arm64": "0.24.0", "@esbuild/openbsd-x64": "0.24.0", "@esbuild/sunos-x64": "0.24.0", "@esbuild/win32-arm64": "0.24.0", "@esbuild/win32-ia32": "0.24.0", "@esbuild/win32-x64": "0.24.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"hast-util-to-estree/style-to-object/inline-style-parser": ["inline-style-parser@0.1.1", "", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="], "hast-util-to-estree/style-to-object/inline-style-parser": ["inline-style-parser@0.1.1", "", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="],
"load-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "load-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"motion/framer-motion/motion-dom": ["motion-dom@11.14.3", "", {}, "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA=="],
"motion/framer-motion/motion-utils": ["motion-utils@11.14.3", "", {}, "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w=="], "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.0", "", { "os": "android", "cpu": "x64" }, "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ=="], "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw=="], "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA=="], "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA=="], "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ=="], "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw=="], "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g=="], "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA=="], "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g=="], "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA=="], "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ=="], "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw=="], "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g=="], "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA=="], "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.0", "", { "os": "none", "cpu": "x64" }, "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg=="], "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q=="], "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA=="], "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA=="], "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw=="], "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA=="], "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core/hast-util-to-html": ["hast-util-to-html@9.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"astro/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw=="],
"astro/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew=="],
"astro/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w=="],
"astro/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.0", "", { "os": "android", "cpu": "x64" }, "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ=="],
"astro/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw=="],
"astro/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA=="],
"astro/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA=="],
"astro/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ=="],
"astro/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw=="],
"astro/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g=="],
"astro/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA=="],
"astro/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g=="],
"astro/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA=="],
"astro/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ=="],
"astro/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw=="],
"astro/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g=="],
"astro/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA=="],
"astro/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.0", "", { "os": "none", "cpu": "x64" }, "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg=="],
"astro/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg=="],
"astro/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q=="],
"astro/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA=="],
"astro/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA=="],
"astro/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw=="],
"astro/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
} }
} }

207
notes.md Normal file
View file

@ -0,0 +1,207 @@
# Event Request Form:
Prior Notes:
Whether you are or aren't requesting AS Funding or physical flyers, you MUST submit this request form at least 6 weeks before your event. We can create both digital and physical flyers for your event, advertise your event on social media (Facebook, Instagram, Discord), advertise your event on newsletters (IEEE, ECE, IDEA), take pictures at your event and edit them (we highly recommend this!), and livestream your event on Facebook. After submitting this form, please @-pr and/or @-coordinators in #-events on Slack.
Please note that if you submit your request late, we may deny your request.
Also note that if you're requesting AS Funding, please don't forget to check the Funding Guide or the Google Calendar for the funding request deadlines.
### Do you need graphics from our design team?
Possible Answers:
- Yes (Go to Section 2)
- No (Go to Section 3)
## Section 2: PR
If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form.
### Type of material needed?
Feel free to add what else you need as well as where else you want your event advertised (if needed) in the other option.
- Digital flyer (with social media advertising: Facebook, Instagram, Discord)
- Digital flyer (with NO social media advertising)
- Physical flyer (with advertising)
- Physical flyer (with NO advertising)
- Newsletter (IEEE, ECE, IDEA)
- Other
### If you chose to have your flyer advertised, when do you need us to start advertising?
- DATETIME
### Logos Required?
[ ] IEEE
[ ] AS (required if funded by AS)
[ ] HKN
[ ] TESC
[ ] PIB
[ ] TNT
[ ] SWE
[ ] OTHER (please upload transparent logo files to the next question)
### Please share your logo files here:
FILEUPLOAD
### What format do you need it to be in?
- PNG
- PDF
- JPG
- DOES NOT MATTER
### Any other specifications and requests (color scheme, overall design, etc)? Feel free to link us to any examples you want us to consider in designing your promotional material. (i.e. past FB covers from events, etc)
TEXTBOX
### Photography Needed?
- Yes
- No
## Section 3: Event Details
Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you.
### Event Name:
TEXTBOX
### Event Description:
TEXTBOX
### Event Start Date:
DATETIME
### Event End Date:
DATETIME
### Event Location:
TEXTBOX
### Do you/will you have a room booking for this event?
- Yes
- No
## Section 4: TAP Form
Please ensure you have ALL sections completed, if something is not available, let the coordinators know and be advised on how to proceed.
### Expected attendance? Include a number NOT a range please.
(
PROGRAMMING FUNDS
EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.
ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.
EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT
)
NUMBER
### Upload your room booking here. Ensure your file size fits within this size. Please use the following naming format: EventName_LocationOfEvent_DateOfEvent
i.e. ArduinoWorkshop_Qualcomm_01/06/2025
FILEUPLOAD
### Do you need AS Funding? (food/flyers)
- Yes
- No
### Will you be serving food/drinks at your event?
- Yes
- No
## Section 5: AS Funding
Please make sure the restaurant is a valid AS Funding food vendor! An invoice can be an unofficial receipt. Just make sure that the restaurant name and location, desired pickup or delivery date and time, all the items ordered plus their prices, discount/fees/tax/tip, and total are on the invoice! We don't recommend paying out of pocket because reimbursements can be a hassle when you're not a Principal Member.
### Please put your invoice information in the following format: quantity + item name + unit cost + discounts/fees/tax/tip + total + vendor.
(e.g. 3-Chicken Cutlet with Gravy Regular, white rice, and mac salad x14.95 each | 3-Garlic Shrimp Regular with white rice and mac salad x15.45 each | 10-Spam Musubi x2.95 each | Tax = 9.35 | Tip = 18.10 | Total = 148.15 from L&L Hawaiian Barbeque)
TEXTBOX
### Be sure to share a screenshot of your order/your official food invoice here. Official food invoices will be required 2 weeks before the start of your event. Please use the following naming format: EventName_OrderLocation_DateOfEvent
i.e. QPWorkathon#1_PapaJohns_01/06/2025
FILEUPLOAD
Pocketbase Collection Schema:
```json
{
"collectionId": "pbc_1475615553",
"collectionName": "event_request",
"id": "test",
"requested_user": "RELATION_RECORD_ID",
"name": "test",
"location": "test",
"start_date_time": "2022-01-01 10:00:00.123Z",
"end_date_time": "2022-01-01 10:00:00.123Z",
"flyers_needed": true,
"flyer_type": [
"digital_with_social",
"digital_no_social",
"physical_with_advertising",
"physical_no_advertising",
"newsletter",
"other"
],
"other_flyer_type": "test",
"flyer_advertising_start_date": "test",
"flyer_additional_requests": "test",
"photography_needed": true,
"required_logos": ["IEEE", "AS", "HKN", "TESC", "PIB", "TNT", "SWE", "OTHER"],
"other_logos": ["filename.jpg"],
"advertising_format": "pdf",
"will_or_have_room_booking": true,
"expected_attendance": 123,
"room_booking": "filename.jpg",
"as_funding_required": true,
"food_drinks_being_served": true,
"itemized_invoice": "JSON",
"invoice": "filename.jpg",
"created": "2022-01-01 10:00:00.123Z",
"updated": "2022-01-01 10:00:00.123Z"
}
```
Possible Flyer Types:
- digital_with_social
- digital_no_social
- physical_with_advertising
- physical_no_advertising
- newsletter
- other
Possible Logos:
[ ] IEEE
[ ] AS
[ ] HKN
[ ] TESC
[ ] PIB
[ ] TNT
[ ] SWE
[ ] OTHER
Possible Advertising Formats:
- pdf
- jpeg
- png
- does_not_matter

9385
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,21 +12,40 @@
"dependencies": { "dependencies": {
"@astrojs/mdx": "4.0.3", "@astrojs/mdx": "4.0.3",
"@astrojs/node": "^9.0.0", "@astrojs/node": "^9.0.0",
"@astrojs/react": "4.1.2", "@astrojs/react": "^4.2.0",
"@astrojs/tailwind": "5.1.4", "@astrojs/tailwind": "5.1.4",
"@types/react": "^18.3.14", "@iconify-json/heroicons": "^1.2.2",
"@types/react-dom": "^18.3.2", "@iconify/react": "^5.2.0",
"@types/highlight.js": "^10.1.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.15",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"astro": "5.1.1", "astro": "5.1.1",
"astro-expressive-code": "^0.38.3", "astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.4", "astro-icon": "^1.1.5",
"chart.js": "^4.4.7",
"dexie": "^4.0.11",
"framer-motion": "^12.4.4",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.2", "next": "^15.1.2",
"react": "^18.3.1", "pocketbase": "^0.25.1",
"react-dom": "^18.3.1", "prismjs": "^1.29.0",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"rehype-expressive-code": "^0.40.2",
"tailwindcss": "^3.4.16" "tailwindcss": "^3.4.16"
}, },
"devDependencies": { "devDependencies": {
"@types/prismjs": "^1.26.5",
"daisyui": "^4.12.23",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"tailwindcss-animated": "^1.1.2", "tailwindcss-animated": "^1.1.2",

View file

@ -0,0 +1,3 @@
---
---

View file

@ -0,0 +1,547 @@
---
import FilePreview from "./universal/FilePreview";
import EventCheckIn from "./EventsSection/EventCheckIn";
import EventLoad from "./EventsSection/EventLoad";
---
<div id="" class="">
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
<p class="opacity-70 text-sm sm:text-base">
View and manage your IEEE UCSD events
</p>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6 px-4 sm:px-6"
>
<!-- Event Check-in Card -->
<div class="w-full">
<EventCheckIn client:load />
</div>
<!-- Event Registration Card -->
<div class="w-full">
<div
class="card bg-base-100 shadow-xl border border-base-200 opacity-50 cursor-not-allowed relative group h-full"
>
<div
class="absolute inset-0 bg-base-100 opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10"
>
<span
class="text-base-content font-medium text-sm sm:text-base"
>Coming Soon</span
>
</div>
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
Event Registration
</h3>
<div class="form-control w-full">
<label class="label">
<span class="label-text text-sm sm:text-base"
>Select an event to register</span
>
</label>
<div class="flex flex-col sm:flex-row gap-2">
<select
class="select select-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
disabled
>
<option disabled selected>Pick an event</option>
<option
>Technical Workshop - Web Development</option
>
<option
>Professional Development Workshop</option
>
<option>Social Event - Game Night</option>
</select>
<button
class="btn btn-primary text-sm sm:text-base h-10 min-h-[2.5rem] w-full sm:w-[100px]"
disabled>Register</button
>
</div>
</div>
</div>
</div>
</div>
</div>
<EventLoad client:load />
</div>
<!-- Event Details Modal -->
<dialog id="eventDetailsModal" class="modal">
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
<div class="flex justify-between items-center mb-3 sm:mb-4">
<div class="flex items-center gap-2 sm:gap-3">
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
Event Files
</h3>
<button
id="downloadAllBtn"
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
onclick="window.downloadAllFiles()"
>
<iconify-icon
icon="heroicons:arrow-down-tray-20-solid"
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
Download All
</button>
</div>
<button
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
onclick="window.closeEventDetailsModal()"
>
<iconify-icon
icon="heroicons:x-mark"
className="h-4 w-4 sm:h-6 sm:w-6"></iconify-icon>
</button>
</div>
<div id="filesContent" class="space-y-3 sm:space-y-4">
<!-- Files list will be populated here -->
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="window.closeEventDetailsModal()">close</button>
</form>
</dialog>
<!-- Universal File Preview Modal -->
<dialog id="filePreviewModal" class="modal">
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
<div class="flex justify-between items-center mb-3 sm:mb-4">
<div class="flex items-center gap-2 sm:gap-3">
<button
class="btn btn-ghost btn-sm text-xs sm:text-sm"
onclick="window.closeFilePreviewEvents()">Close</button
>
<h3
class="font-bold text-base sm:text-lg truncate"
id="previewFileName"
>
</h3>
</div>
</div>
<div class="relative" id="previewContainer">
<div
id="previewLoadingSpinner"
class="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden"
>
<span class="loading loading-spinner loading-md sm:loading-lg"
></span>
</div>
<div id="previewContent" class="w-full">
<FilePreview client:load isModal={true} />
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="window.closeFilePreviewEvents()">close</button>
</form>
</dialog>
<script>
import { toast } from "react-hot-toast";
import JSZip from "jszip";
// Add styles to the document
const style = document.createElement("style");
style.textContent = `
/* Custom styles for the event details modal */
.event-details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.event-details-grid {
grid-template-columns: 1fr;
}
}
/* Remove custom toast styles since we're using react-hot-toast */
`;
document.head.appendChild(style);
// Add helper functions for file preview
function getFileType(filename: string): string {
const extension = filename.split(".").pop()?.toLowerCase();
const mimeTypes: { [key: string]: string } = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
mp4: "video/mp4",
mp3: "audio/mpeg",
txt: "text/plain",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
json: "application/json",
};
return mimeTypes[extension || ""] || "application/octet-stream";
}
// Universal file preview function for events section
window.previewFileEvents = function (url: string, filename: string) {
console.log("previewFileEvents called with:", { url, filename });
console.log("URL type:", typeof url, "URL length:", url?.length || 0);
console.log(
"Filename type:",
typeof filename,
"Filename length:",
filename?.length || 0
);
// Validate inputs
if (!url || typeof url !== "string") {
console.error("Invalid URL provided to previewFileEvents:", url);
toast.error("Cannot preview file: Invalid URL");
return;
}
if (!filename || typeof filename !== "string") {
console.error(
"Invalid filename provided to previewFileEvents:",
filename
);
toast.error("Cannot preview file: Invalid filename");
return;
}
// Ensure URL is properly formatted
if (!url.startsWith("http")) {
console.warn(
"URL doesn't start with http, attempting to fix:",
url
);
if (url.startsWith("/")) {
url = `https://pocketbase.ieeeucsd.org${url}`;
} else {
url = `https://pocketbase.ieeeucsd.org/${url}`;
}
console.log("Fixed URL:", url);
}
const modal = document.getElementById(
"filePreviewModal"
) as HTMLDialogElement;
const previewFileName = document.getElementById("previewFileName");
const previewContent = document.getElementById("previewContent");
const loadingSpinner = document.getElementById("previewLoadingSpinner");
if (modal && previewFileName && previewContent) {
console.log("Found all required elements");
// Show loading spinner
if (loadingSpinner) {
loadingSpinner.classList.remove("hidden");
}
// Update the filename display
previewFileName.textContent = filename;
// Show the modal
modal.showModal();
// Test the URL with a fetch before dispatching the event
fetch(url, { method: "HEAD" })
.then((response) => {
console.log(
"URL test response:",
response.status,
response.ok
);
if (!response.ok) {
console.warn("URL might not be accessible:", url);
toast(
"File might not be accessible. Attempting to preview anyway.",
{
icon: "⚠️",
style: {
borderRadius: "10px",
background: "#FFC107",
color: "#000",
},
}
);
}
})
.catch((err) => {
console.error("Error testing URL:", err);
})
.finally(() => {
// Dispatch state change event to update the FilePreview component
console.log(
"Dispatching filePreviewStateChange event with:",
{ url, filename }
);
window.dispatchEvent(
new CustomEvent("filePreviewStateChange", {
detail: { url, filename },
})
);
});
// Hide loading spinner after a short delay
setTimeout(() => {
if (loadingSpinner) {
loadingSpinner.classList.add("hidden");
}
}, 1000); // Increased delay to allow for URL testing
} else {
console.error("Missing required elements for file preview");
toast.error("Could not initialize file preview");
}
};
// Close file preview for events section
window.closeFilePreviewEvents = function () {
console.log("closeFilePreviewEvents called");
const modal = document.getElementById(
"filePreviewModal"
) as HTMLDialogElement;
const previewFileName = document.getElementById("previewFileName");
const previewContent = document.getElementById("previewContent");
const loadingSpinner = document.getElementById("previewLoadingSpinner");
if (loadingSpinner) {
loadingSpinner.classList.add("hidden");
}
if (modal && previewFileName && previewContent) {
console.log("Resetting preview and closing modal");
// First reset the preview state by dispatching an event with empty values
// This ensures the FilePreview component clears its internal state
window.dispatchEvent(
new CustomEvent("filePreviewStateChange", {
detail: { url: "", filename: "" },
})
);
// Reset the UI
previewFileName.textContent = "";
// Close the modal
modal.close();
console.log("File preview modal closed and state reset");
} else {
console.error("Could not find elements to close file preview");
}
};
// Update the showFilePreview function for events section
window.showFilePreviewEvents = function (file: {
url: string;
name: string;
}) {
console.log("showFilePreviewEvents called with:", file);
if (!file || !file.url || !file.name) {
console.error("Invalid file data:", file);
toast.error("Could not preview file: missing file information");
return;
}
window.previewFileEvents(file.url, file.name);
};
// Update the openDetailsModal function to use the events-specific preview
window.openDetailsModal = function (event: any) {
const modal = document.getElementById(
"eventDetailsModal"
) as HTMLDialogElement;
const filesContent = document.getElementById(
"filesContent"
) as HTMLDivElement;
// Check if event has ended
const eventEndDate = new Date(event.end_date);
const now = new Date();
if (eventEndDate > now) {
toast("Files are only available after the event has ended.", {
icon: "⚠️",
style: {
borderRadius: "10px",
background: "#FFC107",
color: "#000",
},
});
return;
}
// Reset state
window.currentEventId = event.id;
if (filesContent) filesContent.classList.remove("hidden");
// Populate files content
if (
event.files &&
Array.isArray(event.files) &&
event.files.length > 0
) {
const baseUrl = "https://pocketbase.ieeeucsd.org";
const collectionId = "events";
const recordId = event.id;
filesContent.innerHTML = `
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>File Name</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
${event.files
.map((file: string) => {
// Ensure the file URL is properly formatted
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
const fileType = getFileType(file);
// Properly escape the data for the onclick handler
const fileData = {
url: fileUrl,
name: file,
};
return `
<tr>
<td>${file}</td>
<td class="text-right">
<button class="btn btn-ghost btn-sm" onclick="window.showFilePreviewEvents({'url': '${fileUrl}', 'name': '${file}'})">
<iconify-icon icon="heroicons:document" className="h-4 w-4" />
</button>
<a href="${fileUrl}" download="${file}" class="btn btn-ghost btn-sm">
<iconify-icon icon="heroicons:arrow-down-tray-20-solid" className="h-4 w-4" />
</a>
</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
`;
} else {
filesContent.innerHTML = `
<div class="text-center py-8 text-base-content/70">
<iconify-icon icon="heroicons:document-duplicate" className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No files attached to this event</p>
</div>
`;
}
modal.showModal();
};
// Add downloadAllFiles function
window.downloadAllFiles = async function () {
const downloadBtn = document.getElementById(
"downloadAllBtn"
) as HTMLButtonElement;
if (!downloadBtn) return;
const originalBtnContent = downloadBtn.innerHTML;
try {
// Show loading state
downloadBtn.innerHTML =
'<span class="loading loading-spinner loading-xs"></span> Preparing...';
downloadBtn.disabled = true;
const zip = new JSZip();
// Get current event files
const baseUrl = "https://pocketbase.ieeeucsd.org";
const collectionId = "events";
const recordId = window.currentEventId;
// Get the current event from the window object
const eventDataId = `event_${window.currentEventId}`;
const event = window[eventDataId];
if (!event || !event.files || event.files.length === 0) {
throw new Error("No files available to download");
}
// Download each file and add to zip
const filePromises = event.files.map(async (filename: string) => {
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to download ${filename}`);
}
const blob = await response.blob();
zip.file(filename, blob);
});
await Promise.all(filePromises);
// Generate and download zip
const zipBlob = await zip.generateAsync({ type: "blob" });
const downloadUrl = URL.createObjectURL(zipBlob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${event.event_name}_files.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
// Show success message
toast.success("Files downloaded successfully!");
} catch (error: any) {
console.error("Failed to download files:", error);
toast.error(
error?.message || "Failed to download files. Please try again."
);
} finally {
// Reset button state
downloadBtn.innerHTML = originalBtnContent;
downloadBtn.disabled = false;
}
};
// Close event details modal
window.closeEventDetailsModal = function () {
const modal = document.getElementById(
"eventDetailsModal"
) as HTMLDialogElement;
const filesContent = document.getElementById("filesContent");
if (modal) {
// Reset the files content
if (filesContent) {
filesContent.innerHTML = "";
}
// Reset any other state if needed
window.currentEventId = "";
// Close the modal
modal.close();
}
};
// Make helper functions available globally
window.showFilePreview = window.showFilePreviewEvents;
window.handlePreviewError = function () {
const previewContent = document.getElementById("previewContent");
if (previewContent) {
previewContent.innerHTML = `
<div class="alert alert-error">
<Icon icon="heroicons:x-circle" className="h-6 w-6" />
<span>Failed to load file preview</span>
</div>
`;
}
};
</script>

View file

@ -0,0 +1,525 @@
import { useState, useEffect } from "react";
import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Update } from "../../../scripts/pocketbase/Update";
import { SendLog } from "../../../scripts/pocketbase/SendLog";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import { Icon } from "@iconify/react";
import toast from "react-hot-toast";
import type { Event, EventAttendee } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description'
}
// Note: Date conversion is now handled automatically by the Get and Update classes.
// When fetching events, UTC dates are converted to local time.
// When saving events, local dates are converted back to UTC.
const EventCheckIn = () => {
const [currentCheckInEvent, setCurrentCheckInEvent] = useState<Event | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [foodInput, setFoodInput] = useState("");
// SECURITY FIX: Purge event codes when component mounts
useEffect(() => {
const dataSync = DataSyncService.getInstance();
dataSync.purgeEventCodes().catch(err => {
console.error("Error purging event codes:", err);
});
}, []);
async function handleEventCheckIn(eventCode: string): Promise<void> {
try {
const get = Get.getInstance();
const auth = Authentication.getInstance();
const dataSync = DataSyncService.getInstance();
const logger = SendLog.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
await logger.send(
"error",
"event_check_in",
"Check-in failed: User not logged in"
);
toast.error("You must be logged in to check in to events");
return;
}
// Log the check-in attempt
await logger.send(
"info",
"event_check_in",
`Attempting to check in with code: ${eventCode}`
);
// Validate event code
if (!eventCode || eventCode.trim() === "") {
await logger.send(
"error",
"event_check_in",
"Check-in failed: Empty event code"
);
toast.error("Please enter an event code");
return;
}
// Get event by code
const events = await get.getList<Event>(
Collections.EVENTS,
1,
1,
`event_code="${eventCode}"`
);
if (events.totalItems === 0) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Invalid event code: ${eventCode}`
);
toast.error("Invalid event code. Please try again.");
return;
}
const event = events.items[0];
// Check if event is published
if (!event.published) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Event not published: ${event.event_name}`
);
toast.error("This event is not currently available for check-in");
return;
}
// Check if the event is active (has started and hasn't ended yet)
const currentTime = new Date();
const eventStartDate = new Date(event.start_date);
const eventEndDate = new Date(event.end_date);
if (currentTime < eventStartDate) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Event has not started yet: ${event.event_name}`
);
toast.error(`This event hasn't started yet. It begins on ${eventStartDate.toLocaleDateString()} at ${eventStartDate.toLocaleTimeString()}`);
return;
}
if (currentTime > eventEndDate) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Event has already ended: ${event.event_name}`
);
toast.error("This event has already ended");
return;
}
// Check if user is already checked in - IMPROVED VALIDATION
const attendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
50, // Increased limit to ensure we catch all possible duplicates
`user="${currentUser.id}" && event="${event.id}"`
);
if (attendees.totalItems > 0) {
const lastCheckIn = new Date(attendees.items[0].time_checked_in);
const timeSinceLastCheckIn = Date.now() - lastCheckIn.getTime();
const hoursAgo = Math.round(timeSinceLastCheckIn / (1000 * 60 * 60));
await logger.send(
"error",
"event_check_in",
`Check-in failed: Already checked in to event: ${event.event_name} (${hoursAgo} hours ago)`
);
toast.error(`You have already checked in to this event (${hoursAgo} hours ago)`);
return;
}
// Set current event for check-in
setCurrentCheckInEvent(event);
// Log successful event lookup
await logger.send(
"info",
"event_check_in",
`Found event for check-in: ${event.event_name}`
);
// Store event code in local storage for offline check-in
await dataSync.storeEventCode(eventCode);
// Show event details toast only for non-food events
// For food events, we'll show the toast after food selection
if (!event.has_food) {
toast.success(
<div>
<strong>Event found!</strong>
<p className="text-sm mt-1">{event.event_name}</p>
<p className="text-xs mt-1">
{event.points_to_reward > 0 ? `${event.points_to_reward} points` : "No points"}
</p>
</div>,
{ duration: 5000 }
);
}
// If event has food, show food selection modal
if (event.has_food) {
// Show food-specific toast
toast.success(
<div>
<strong>Event with food found!</strong>
<p className="text-sm mt-1">{event.event_name}</p>
<p className="text-xs mt-1">Please select your food preference</p>
</div>,
{ duration: 5000 }
);
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
if (modal) modal.showModal();
} else {
// If no food, show confirmation modal
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
if (modal) modal.showModal();
}
} catch (error: any) {
console.error("Error checking in:", error);
toast.error(error.message || "An error occurred during check-in");
}
}
async function completeCheckIn(event: Event, foodSelection: string | null): Promise<void> {
try {
setIsLoading(true);
const auth = Authentication.getInstance();
const update = Update.getInstance();
const logger = SendLog.getInstance();
const dataSync = DataSyncService.getInstance();
const get = Get.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
throw new Error("You must be logged in to check in to events");
}
const userId = currentUser.id;
const eventId = event.id;
// Double-check for existing check-ins with improved validation
const existingAttendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
50, // Increased limit to ensure we catch all possible duplicates
`user="${userId}" && event="${eventId}"`
);
if (existingAttendees.totalItems > 0) {
const lastCheckIn = new Date(existingAttendees.items[0].time_checked_in);
const timeSinceLastCheckIn = Date.now() - lastCheckIn.getTime();
const hoursAgo = Math.round(timeSinceLastCheckIn / (1000 * 60 * 60));
await logger.send(
"error",
"event_check_in",
`Check-in failed: Already checked in to event: ${event.event_name} (${hoursAgo} hours ago)`
);
throw new Error(`You have already checked in to this event (${hoursAgo} hours ago)`);
}
// Create new attendee record with transaction to prevent race conditions
const attendeeData = {
user: userId,
event: eventId,
food_ate: foodSelection || "",
time_checked_in: new Date().toISOString(),
points_earned: event.points_to_reward || 0
};
try {
// Create the attendee record in PocketBase
const newAttendee = await update.create(Collections.EVENT_ATTENDEES, attendeeData);
console.log("Successfully created attendance record");
// Update user's total points
// First, get all the user's attendance records to calculate total points
const userAttendance = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
1000,
`user="${userId}"`
);
// Calculate total points
let totalPoints = 0;
userAttendance.items.forEach(attendee => {
totalPoints += attendee.points_earned || 0;
});
// Log the points update
console.log(`Updating user points to: ${totalPoints}`);
// Update the user record with the new total points
await update.updateFields(Collections.USERS, userId, {
points: totalPoints
});
// Ensure local data is in sync with backend
// First sync the new attendance record
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
// Then sync the updated user data to ensure points are correctly reflected locally
await dataSync.syncCollection(Collections.USERS);
// Clear event code from local storage
await dataSync.clearEventCode();
// Log successful check-in
await logger.send(
"info",
"event_check_in",
`Successfully checked in to event: ${event.event_name}`
);
// Show success message with event name and points
const pointsMessage = event.points_to_reward > 0
? ` (+${event.points_to_reward} points!)`
: "";
toast.success(`Successfully checked in to ${event.event_name}${pointsMessage}`);
// Close any open modals
const foodModal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
if (foodModal) foodModal.close();
const confirmModal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
if (confirmModal) confirmModal.close();
setCurrentCheckInEvent(null);
setFoodInput("");
} catch (createError: any) {
console.error("Error creating attendance record:", createError);
// Check if this is a duplicate record error (race condition handling)
if (createError.status === 400 && createError.data?.data?.user?.code === "validation_not_unique") {
throw new Error("You have already checked in to this event");
}
throw createError;
}
} catch (error: any) {
console.error("Error completing check-in:", error);
toast.error(error.message || "An error occurred during check-in");
} finally {
setIsLoading(false);
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentCheckInEvent) return;
try {
const auth = Authentication.getInstance();
const logger = SendLog.getInstance();
const get = Get.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
throw new Error("You must be logged in to check in to events");
}
// Additional check to prevent duplicate check-ins right before submission
const existingAttendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
1,
`user="${currentUser.id}" && event="${currentCheckInEvent.id}"`
);
// Check if user is already checked in
if (existingAttendees.totalItems > 0) {
throw new Error("You have already checked in to this event");
}
// Complete check-in with food selection
await completeCheckIn(currentCheckInEvent, foodInput);
} catch (error: any) {
console.error("Error submitting check-in:", error);
toast.error(error.message || "An error occurred during check-in");
}
};
return (
<>
<div className="card bg-base-100 shadow-xl border border-base-200 h-full">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
<div className="form-control w-full">
<label className="label">
<span className="label-text text-sm sm:text-base">Enter event code to check in</span>
</label>
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector('input') as HTMLInputElement;
if (input.value.trim()) {
setIsLoading(true);
handleEventCheckIn(input.value.trim()).finally(() => {
setIsLoading(false);
input.value = "";
});
} else {
toast("Please enter an event code", {
icon: '⚠️',
style: {
borderRadius: '10px',
background: '#FFC107',
color: '#000',
},
});
}
}}>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="password"
placeholder="Enter code"
className="input input-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
onKeyPress={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
/>
<button
type="submit"
className="btn btn-primary h-10 min-h-[2.5rem] text-sm sm:text-base w-full sm:w-auto"
disabled={isLoading}
>
{isLoading ? (
<Icon
icon="line-md:loading-twotone-loop"
className="w-5 h-5"
inline={true}
/>
) : (
"Check In"
)}
</button>
</div>
</form>
</div>
</div>
</div>
{/* Food Selection Modal */}
<dialog id="foodSelectionModal" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
<div className="text-sm mb-4 opacity-75">
{currentCheckInEvent?.event_description}
</div>
<div className="badge badge-primary mb-4">
{currentCheckInEvent?.points_to_reward} points
</div>
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
<form onSubmit={handleSubmit}>
<div className="form-control">
<input
type="text"
placeholder="Enter your food preference"
className="input input-bordered w-full"
value={foodInput}
onChange={(e) => setFoodInput(e.target.value)}
required
/>
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={() => {
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
modal.close();
setCurrentCheckInEvent(null);
}}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={isLoading}>
{isLoading ? (
<Icon
icon="line-md:loading-twotone-loop"
className="w-5 h-5"
inline={true}
/>
) : (
"Check In"
)}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
</dialog>
{/* Confirmation Modal (for events without food) */}
<dialog id="confirmCheckInModal" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
<div className="text-sm mb-4 opacity-75">
{currentCheckInEvent?.event_description}
</div>
<div className="badge badge-primary mb-4">
{currentCheckInEvent?.points_to_reward} points
</div>
<p className="mb-4">Are you sure you want to check in to this event?</p>
<div className="modal-action">
<button
type="button"
className="btn"
onClick={() => {
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
modal.close();
setCurrentCheckInEvent(null);
}}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
disabled={isLoading}
onClick={() => {
if (currentCheckInEvent) {
completeCheckIn(currentCheckInEvent, null);
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
modal.close();
}
}}
>
{isLoading ? (
<Icon
icon="line-md:loading-twotone-loop"
className="w-5 h-5"
inline={true}
/>
) : (
"Confirm Check In"
)}
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
</dialog>
</>
);
};
export default EventCheckIn;

View file

@ -0,0 +1,595 @@
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { DexieService } from "../../../scripts/database/DexieService";
import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description'
}
declare global {
interface Window {
openDetailsModal: (event: ExtendedEvent) => void;
downloadAllFiles: () => Promise<void>;
currentEventId: string;
[key: string]: any;
}
}
// Helper function to validate event data integrity
const isValidEvent = (event: any): boolean => {
if (!event || typeof event !== 'object') return false;
// Check required fields
if (!event.id || !event.event_name) return false;
// Validate date fields
try {
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
// Check if dates are valid
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
console.warn(`Event ${event.id} has invalid date format`, {
start: event.start_date,
end: event.end_date
});
return false;
}
return true;
} catch (error) {
console.warn(`Error validating event ${event?.id || 'unknown'}:`, error);
return false;
}
};
const EventLoad = () => {
const [events, setEvents] = useState<{
upcoming: Event[];
ongoing: Event[];
past: Event[];
}>({
upcoming: [],
ongoing: [],
past: [],
});
const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
// Function to clear the events cache and force a fresh sync
const refreshEvents = async () => {
try {
setRefreshing(true);
// Get DexieService instance
const dexieService = DexieService.getInstance();
const db = dexieService.getDB();
// Clear events table
if (db && db.events) {
console.log("Clearing events cache...");
await db.events.clear();
console.log("Events cache cleared successfully");
}
// Reset sync timestamp for events by updating it to 0
// First get the current record
const currentInfo = await dexieService.getLastSync(Collections.EVENTS);
// Then update it with a timestamp of 0 (forcing a fresh sync)
await dexieService.updateLastSync(Collections.EVENTS);
console.log("Events sync timestamp reset");
// Reload events
setLoading(true);
await loadEvents();
} catch (error) {
console.error("Error refreshing events:", error);
setErrorMessage("Failed to refresh events. Please try again.");
} finally {
setRefreshing(false);
}
};
useEffect(() => {
loadEvents();
}, []);
const createSkeletonCard = () => (
<div className="card bg-base-200 shadow-lg animate-pulse">
<div className="card-body p-5">
<div className="flex flex-col h-full">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1">
<div className="skeleton h-6 w-3/4 mb-2"></div>
<div className="flex items-center gap-2">
<div className="skeleton h-5 w-16"></div>
<div className="skeleton h-5 w-20"></div>
</div>
</div>
<div className="flex flex-col items-end">
<div className="skeleton h-5 w-24 mb-1"></div>
<div className="skeleton h-4 w-16"></div>
</div>
</div>
<div className="skeleton h-4 w-full mb-3"></div>
<div className="flex items-center gap-2">
<div className="skeleton h-4 w-4"></div>
<div className="skeleton h-4 w-1/2"></div>
</div>
</div>
</div>
</div>
);
const renderEventCard = (event: Event) => {
try {
// Get authentication instance
const auth = Authentication.getInstance();
const currentUser = auth.getCurrentUser();
// Check if user has attended this event by querying the event_attendees collection
let hasAttended = false;
if (currentUser) {
// We'll check attendance status when displaying the card
// This will be done asynchronously after rendering
setTimeout(async () => {
try {
const get = Get.getInstance();
const attendees = await get.getList<EventAttendee>(
"event_attendees",
1,
1,
`user="${currentUser.id}" && event="${event.id}"`
);
const hasAttendedEvent = attendees.totalItems > 0;
// Update the card UI based on attendance status
const cardElement = document.getElementById(`event-card-${event.id}`);
if (cardElement && hasAttendedEvent) {
const attendedBadge = cardElement.querySelector('.attended-badge');
if (attendedBadge) {
(attendedBadge as HTMLElement).style.display = 'flex';
}
}
} catch (error) {
console.error("Error checking attendance status:", error);
}
}, 0);
}
// Store event data in window object with unique ID
const eventDataId = `event_${event.id}`;
window[eventDataId] = event;
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
const now = new Date();
const isPastEvent = endDate < now;
return (
<div key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
<div className="card-body p-3 sm:p-4">
<div className="flex flex-col h-full">
<div className="flex flex-col gap-2">
<div className="flex-1">
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2">{event.event_name}</h3>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-base-content/70">
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
<div className="text-xs sm:text-sm opacity-75">
{startDate.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
{" • "}
{startDate.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</div>
</div>
</div>
</div>
<div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2">
{event.event_description || "No description available"}
</div>
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
{event.files && event.files.length > 0 && (
<button
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2"
>
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
Files ({event.files.length})
</button>
)}
{isPastEvent && (
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}>
<Icon
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
className="h-3 w-3"
/>
{hasAttended ? 'Attended' : 'Not Attended'}
</div>
)}
<div className="text-xs sm:text-sm opacity-75 ml-auto">
{event.location}
</div>
</div>
</div>
</div>
</div>
);
} catch (error) {
console.error("Error rendering event card:", error);
return null;
}
};
const loadEvents = async () => {
try {
const get = Get.getInstance();
const dataSync = DataSyncService.getInstance();
const auth = Authentication.getInstance();
console.log("Starting to load events...");
// Check if user is authenticated
if (!auth.isAuthenticated()) {
console.error("User not authenticated, cannot load events");
setLoading(false);
return;
}
// Force sync to ensure we have the latest data
console.log("Syncing events collection...");
let syncSuccess = false;
let retryCount = 0;
const maxRetries = 3;
while (!syncSuccess && retryCount < maxRetries) {
try {
if (retryCount > 0) {
console.log(`Retry attempt ${retryCount} of ${maxRetries}...`);
// Add a small delay between retries
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date");
console.log("Events collection synced successfully");
syncSuccess = true;
} catch (syncError) {
retryCount++;
console.error(`Error syncing events collection (attempt ${retryCount}/${maxRetries}):`, syncError);
if (retryCount >= maxRetries) {
console.warn("Max retry attempts reached, continuing with local data");
}
}
}
// Get events from IndexedDB
console.log("Fetching events from IndexedDB...");
const allEvents = await dataSync.getData<Event>(
Collections.EVENTS,
false, // Don't force sync again
"published = true",
"-start_date"
);
console.log(`Retrieved ${allEvents.length} events from IndexedDB`);
// Filter out invalid events
const validEvents = allEvents.filter(event => isValidEvent(event));
console.log(`Filtered out ${allEvents.length - validEvents.length} invalid events`);
// If no valid events found in IndexedDB, try fetching directly from PocketBase as fallback
let eventsToProcess = validEvents;
if (allEvents.length === 0) {
console.log("No events found in IndexedDB, trying direct PocketBase fetch...");
try {
const pbEvents = await get.getAll<Event>(
Collections.EVENTS,
"published = true",
"-start_date"
);
console.log(`Retrieved ${pbEvents.length} events directly from PocketBase`);
// Filter out invalid events from PocketBase results
const validPbEvents = pbEvents.filter(event => isValidEvent(event));
console.log(`Filtered out ${pbEvents.length - validPbEvents.length} invalid events from PocketBase`);
eventsToProcess = validPbEvents;
// Store these events in IndexedDB for future use
if (validPbEvents.length > 0) {
const dexieService = DexieService.getInstance();
const db = dexieService.getDB();
if (db && db.events) {
console.log(`Storing ${validPbEvents.length} valid PocketBase events in IndexedDB...`);
await db.events.bulkPut(validPbEvents);
}
}
} catch (pbError) {
console.error("Error fetching events from PocketBase:", pbError);
}
}
// Split events into upcoming, ongoing, and past based on start and end dates
console.log("Categorizing events...");
const now = new Date();
const { upcoming, ongoing, past } = eventsToProcess.reduce(
(acc, event) => {
try {
// Convert UTC dates to local time
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
// Set both dates and now to midnight for date-only comparison
const startLocal = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startDate.getHours(),
startDate.getMinutes()
);
const endLocal = new Date(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate(),
endDate.getHours(),
endDate.getMinutes()
);
const nowLocal = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes()
);
if (startLocal > nowLocal) {
acc.upcoming.push(event);
} else if (endLocal < nowLocal) {
acc.past.push(event);
} else {
acc.ongoing.push(event);
}
} catch (dateError) {
console.error("Error processing event dates:", dateError, event);
// If we can't process dates properly, put in past events as fallback
acc.past.push(event);
}
return acc;
},
{
upcoming: [] as Event[],
ongoing: [] as Event[],
past: [] as Event[],
}
);
console.log(`Categorized events: ${upcoming.length} upcoming, ${ongoing.length} ongoing, ${past.length} past`);
// Sort events
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
ongoing.sort((a, b) => new Date(a.end_date).getTime() - new Date(b.end_date).getTime());
past.sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime());
setEvents({
upcoming: upcoming.slice(0, 50), // Limit to 50 events per section
ongoing: ongoing.slice(0, 50),
past: past.slice(0, 50)
});
setLoading(false);
} catch (error) {
console.error("Failed to load events:", error);
// Attempt to diagnose the error
if (error instanceof Error) {
console.error(`Error type: ${error.name}, Message: ${error.message}`);
console.error(`Stack trace: ${error.stack}`);
// Check for network-related errors
if (error.message.includes('network') || error.message.includes('fetch') || error.message.includes('connection')) {
console.error("Network-related error detected");
// Try to load from IndexedDB only as a last resort
try {
console.log("Attempting to load events from IndexedDB only...");
const dexieService = DexieService.getInstance();
const db = dexieService.getDB();
if (db && db.events) {
const allCachedEvents = await db.events.filter(event => event.published === true).toArray();
console.log(`Found ${allCachedEvents.length} cached events in IndexedDB`);
// Filter out invalid events
const cachedEvents = allCachedEvents.filter(event => isValidEvent(event));
console.log(`Filtered out ${allCachedEvents.length - cachedEvents.length} invalid cached events`);
if (cachedEvents.length > 0) {
// Process these events
const now = new Date();
const { upcoming, ongoing, past } = cachedEvents.reduce(
(acc, event) => {
try {
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
if (startDate > now) {
acc.upcoming.push(event);
} else if (endDate < now) {
acc.past.push(event);
} else {
acc.ongoing.push(event);
}
} catch (e) {
acc.past.push(event);
}
return acc;
},
{
upcoming: [] as Event[],
ongoing: [] as Event[],
past: [] as Event[],
}
);
// Sort and set events
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
ongoing.sort((a, b) => new Date(a.end_date).getTime() - new Date(b.end_date).getTime());
past.sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime());
setEvents({
upcoming: upcoming.slice(0, 50),
ongoing: ongoing.slice(0, 50),
past: past.slice(0, 50)
});
console.log("Successfully loaded events from cache");
}
}
} catch (cacheError) {
console.error("Failed to load events from cache:", cacheError);
}
}
}
setLoading(false);
}
};
if (loading) {
return (
<>
{/* Ongoing Events */}
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => (
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
))}
</div>
</div>
</div>
{/* Upcoming Events */}
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => (
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
))}
</div>
</div>
</div>
{/* Past Events */}
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => (
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
))}
</div>
</div>
</div>
</>
);
}
// Check if there are no events at all
const noEvents = events.ongoing.length === 0 && events.upcoming.length === 0 && events.past.length === 0;
return (
<>
{/* No Events Message */}
{noEvents && (
<div className="card bg-base-100 shadow-xl border border-base-200 mx-4 sm:mx-6 p-8">
<div className="text-center">
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-base-content/30 mb-4" />
<h3 className="text-xl font-bold mb-2">No Events Found</h3>
<p className="text-base-content/70 mb-4">
There are currently no events to display. This could be due to:
</p>
<ul className="list-disc text-left max-w-md mx-auto text-base-content/70 mb-6">
<li className="mb-1">No events have been published yet</li>
<li className="mb-1">There might be a connection issue with the event database</li>
<li className="mb-1">The events data might be temporarily unavailable</li>
</ul>
<button
onClick={refreshEvents}
className="btn btn-primary"
disabled={refreshing}
>
{refreshing ? (
<>
<Icon icon="heroicons:arrow-path" className="w-5 h-5 mr-2 animate-spin" />
Refreshing...
</>
) : (
<>
<Icon icon="heroicons:arrow-path" className="w-5 h-5 mr-2" />
Refresh Events
</>
)}
</button>
</div>
</div>
)}
{/* Ongoing Events */}
{events.ongoing.length > 0 && (
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.ongoing.map(renderEventCard)}
</div>
</div>
</div>
)}
{/* Upcoming Events */}
{events.upcoming.length > 0 && (
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.upcoming.map(renderEventCard)}
</div>
</div>
</div>
)}
{/* Past Events */}
{events.past.length > 0 && (
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.past.map(renderEventCard)}
</div>
</div>
</div>
)}
</>
);
};
export default EventLoad;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,701 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { Icon } from "@iconify/react";
import type { Event, User as SchemaUser, EventAttendee } from "../../../schemas/pocketbase";
import toast from "react-hot-toast";
// Extended User interface with additional properties needed for this component
interface User extends SchemaUser {
member_type: string;
}
// Define AttendeeEntry interface locally
interface AttendeeEntry {
user_id: string;
time_checked_in: string;
food: string;
points_earned?: number;
}
// Cache for storing user data
const userCache = new Map<string, {
data: User;
timestamp: number;
}>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const ITEMS_PER_PAGE = 50;
// Add HighlightText component
const HighlightText = ({ text, searchTerms }: { text: string | number | null | undefined, searchTerms: string[] }) => {
// Convert input to string and handle null/undefined
const textStr = String(text ?? '');
if (!searchTerms.length || !textStr) return <>{textStr}</>;
try {
const escapedTerms = searchTerms.map(term =>
term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
const parts = textStr.split(new RegExp(`(${escapedTerms.join('|')})`, 'gi'));
return (
<>
{parts.map((part, i) => {
const isMatch = searchTerms.some(term =>
part.toLowerCase().includes(term.toLowerCase())
);
return isMatch ? (
<mark key={i} className="bg-primary/20 rounded px-1">{part}</mark>
) : (
<span key={i}>{part}</span>
);
})}
</>
);
} catch (error) {
console.error('Error in HighlightText:', error);
return <>{textStr}</>;
}
};
// Add new interface for selected fields
interface EventFields {
id: true;
event_name: true;
}
interface UserFields {
id: true;
name: true;
email: true;
pid: true;
member_id: true;
member_type: true;
graduation_year: true;
major: true;
}
// Constants for field selection
const EVENT_FIELDS: (keyof EventFields)[] = ['id', 'event_name'];
const USER_FIELDS: (keyof UserFields)[] = ['id', 'name', 'email', 'pid', 'member_id', 'member_type', 'graduation_year', 'major'];
export default function Attendees() {
const [eventId, setEventId] = useState<string>('');
const [eventName, setEventName] = useState<string>('');
const [users, setUsers] = useState<Map<string, User>>(new Map());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attendeesList, setAttendeesList] = useState<AttendeeEntry[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
const [refreshKey, setRefreshKey] = useState(0); // Add a refresh key to force re-fetching
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Memoize search terms processing
const updateProcessedSearchTerms = useCallback((searchTerm: string) => {
const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean);
setProcessedSearchTerms(terms);
setCurrentPage(1); // Reset to first page on new search
}, []);
// Memoize filtered attendees
const filteredAttendees = useMemo(() => {
if (!searchTerm.trim()) return attendeesList;
return attendeesList.filter(attendee => {
const user = users.get(attendee.user_id);
if (!user) return false;
const searchableValues = [
user.name,
user.email,
user.pid,
user.member_id,
user.member_type,
user.graduation_year,
user.major,
attendee.food,
new Date(attendee.time_checked_in).toLocaleString(),
].map(value => (value || '').toString().toLowerCase());
return processedSearchTerms.every(term =>
searchableValues.some(value => value.includes(term))
);
});
}, [attendeesList, users, processedSearchTerms]);
// Memoize paginated attendees
const paginatedAttendees = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredAttendees.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [filteredAttendees, currentPage]);
// Memoize pagination info
const paginationInfo = useMemo(() => {
const totalPages = Math.ceil(filteredAttendees.length / ITEMS_PER_PAGE);
return {
totalPages,
hasNextPage: currentPage < totalPages,
hasPrevPage: currentPage > 1
};
}, [filteredAttendees.length, currentPage]);
// Optimized user data fetching with cache
const fetchUserData = useCallback(async (userIds: string[]) => {
if (!userIds.length) return new Map<string, User>();
const now = Date.now();
const uncachedIds: string[] = [];
const cachedUsers = new Map<string, User>();
// Check cache first
userIds.forEach(id => {
const cached = userCache.get(id);
if (cached && now - cached.timestamp < CACHE_DURATION) {
cachedUsers.set(id, cached.data);
} else {
uncachedIds.push(id);
}
});
// If we have all users in cache, return early
if (uncachedIds.length === 0) {
return cachedUsers;
}
try {
// Create a filter to get all uncached users in one request
const userFilter = uncachedIds.map(id => `id="${id}"`).join(" || ");
// Fetch all uncached users in one request
const usersResponse = await get.getAll<User>(
Collections.USERS,
userFilter
);
// Process the fetched users
usersResponse.forEach(user => {
// Add member_type if it doesn't exist
const userWithMemberType = {
...user,
member_type: user.member_type || "N/A"
};
cachedUsers.set(user.id, userWithMemberType);
// Update cache
userCache.set(user.id, {
data: userWithMemberType,
timestamp: now
});
});
// Create placeholders for any users that weren't found
const fetchedIds = new Set(usersResponse.map(user => user.id));
uncachedIds.forEach(id => {
if (!fetchedIds.has(id) && !cachedUsers.has(id)) {
// Create a placeholder user
const placeholderUser: User = {
id,
name: `User ${id}`,
email: "N/A",
emailVisibility: false,
verified: false,
created: "",
updated: "",
member_type: "N/A"
};
cachedUsers.set(id, placeholderUser);
}
});
} catch (error) {
console.error('Failed to fetch users:', error);
// Create placeholders for all uncached users that failed to fetch
uncachedIds.forEach(id => {
if (!cachedUsers.has(id)) {
const placeholderUser: User = {
id,
name: `User ${id}`,
email: "N/A",
emailVisibility: false,
verified: false,
created: "",
updated: "",
member_type: "N/A"
};
cachedUsers.set(id, placeholderUser);
}
});
}
return cachedUsers;
}, []);
// Function to refresh attendees data
const refreshAttendees = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);
// Listen for the custom event
useEffect(() => {
const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => {
setEventId(e.detail.eventId);
setEventName(e.detail.eventName);
setCurrentPage(1); // Reset pagination on new event
setSearchTerm(''); // Clear search on new event
// Log the attendees view action
try {
const sendLog = SendLog.getInstance();
await sendLog.send(
"view",
"event_attendees",
`Viewed attendees for event: ${e.detail.eventName}`
);
} catch (error) {
console.error('Failed to log attendees view:', error);
}
};
window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
// Expose refresh function to window
(window as any).refreshAttendees = refreshAttendees;
return () => {
window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
delete (window as any).refreshAttendees;
};
}, [refreshAttendees]);
// Update search terms when search input changes
useEffect(() => {
updateProcessedSearchTerms(searchTerm);
}, [searchTerm, updateProcessedSearchTerms]);
// Fetch event data when eventId changes or refreshKey changes
useEffect(() => {
let isMounted = true;
const fetchEventData = async () => {
if (!eventId || !auth.isAuthenticated()) {
if (!auth.isAuthenticated()) {
console.log('User not authenticated');
setError('Authentication required');
}
return;
}
try {
setLoading(true);
setError(null);
if (!eventId) {
setAttendeesList([]);
setUsers(new Map());
return;
}
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
await dataSync.syncCollection(Collections.EVENTS, `id="${eventId}"`);
const event = await get.getOne<Event>(Collections.EVENTS, eventId);
if (!event) {
setError("Event not found");
setAttendeesList([]);
setUsers(new Map());
return;
}
// Fetch attendees from event_attendees collection with a higher limit
const attendeesList = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
2000, // Increased limit to handle more attendees
`event="${eventId}"`
);
if (!attendeesList.items.length) {
if (isMounted) {
setAttendeesList([]);
setUsers(new Map());
}
return;
}
// Transform EventAttendee records to match the expected format
const transformedAttendees = attendeesList.items.map(attendee => ({
user_id: attendee.user, // This is the user ID (relation)
time_checked_in: attendee.time_checked_in,
food: attendee.food_ate,
points_earned: attendee.points_earned
}));
if (isMounted) {
setAttendeesList(transformedAttendees);
}
// Fetch all users at once to improve performance
const userIds = transformedAttendees.map(a => a.user_id);
// Create a filter to get all users in one request
const userFilter = userIds.map(id => `id="${id}"`).join(" || ");
try {
// Fetch all users directly from PocketBase in one request
const usersResponse = await get.getAll<User>(
Collections.USERS,
userFilter
);
// Create a map of users
const userMap = new Map<string, User>();
usersResponse.forEach(user => {
// Add member_type if it doesn't exist
const userWithMemberType = {
...user,
member_type: user.member_type || "N/A"
};
userMap.set(user.id, userWithMemberType);
// Update cache
userCache.set(user.id, {
data: userWithMemberType,
timestamp: Date.now()
});
});
// For any missing users, create placeholders
userIds.forEach(id => {
if (!userMap.has(id)) {
const placeholderUser: User = {
id,
name: `User ${id}`,
email: "N/A",
emailVisibility: false,
verified: false,
created: "",
updated: "",
member_type: "N/A"
};
userMap.set(id, placeholderUser);
}
});
if (isMounted) {
setUsers(userMap);
}
} catch (error) {
console.error("Failed to fetch users:", error);
// Fallback to individual user fetching
const userMap = await fetchUserData(userIds);
if (isMounted) {
setUsers(userMap);
}
}
toast.success(`Loaded ${attendeesList.items.length} attendees for ${event.event_name}`);
} catch (error) {
console.error("Error fetching event data:", error);
setError("Failed to load event data. Please try refreshing.");
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchEventData();
return () => { isMounted = false; };
}, [eventId, auth, fetchUserData, refreshKey]);
// Reset state when modal is closed
useEffect(() => {
const handleModalClose = () => {
setEventId('');
setEventName('');
setAttendeesList([]);
setUsers(new Map());
setError(null);
setSearchTerm('');
setCurrentPage(1);
};
const modal = document.getElementById('attendeesModal');
if (modal) {
modal.addEventListener('close', handleModalClose);
return () => modal.removeEventListener('close', handleModalClose);
}
}, []);
// Function to download attendees as CSV
const downloadAttendeesCSV = () => {
// Function to sanitize and format CSV cell content
const escapeCSV = (cell: any): string => {
// Convert to string and replace any newlines with spaces
const value = (cell?.toString() || '').replace(/[\r\n]+/g, ' ').trim();
// If the value contains quotes or commas, wrap in quotes and escape internal quotes
if (value.includes('"') || value.includes(',')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
// Create CSV headers
const headers = [
'Name',
'Email',
'PID',
'Member ID',
'Member Type',
'Graduation Year',
'Major',
'Check-in Time',
'Food Choice',
'Points Earned'
].map(escapeCSV);
// Create CSV rows
const rows = attendeesList.map(attendee => {
const user = users.get(attendee.user_id);
let checkInTime = '';
try {
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
} catch (e) {
checkInTime = attendee.time_checked_in || 'N/A';
}
return [
user?.name || `User ${attendee.user_id}`,
user?.email || 'N/A',
user?.pid || 'N/A',
user?.member_id || 'N/A',
user?.member_type || 'N/A',
user?.graduation_year || 'N/A',
user?.major || 'N/A',
checkInTime,
attendee.food || 'N/A',
attendee.points_earned || 'N/A'
].map(escapeCSV);
});
// Combine headers and rows with Windows-style line endings
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\r\n');
// Create blob with UTF-8 BOM for better Excel compatibility
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Create filename with date and time
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const filename = `${eventName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_attendees_${timestamp}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url); // Clean up the URL object
toast.success(`Downloaded ${rows.length} attendee records`);
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<Icon icon="heroicons:exclamation-circle" className="h-6 w-6" />
<span>{error}</span>
<button
className="btn btn-sm btn-outline"
onClick={refreshAttendees}
>
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
Retry
</button>
</div>
);
}
if (!eventId) {
return null;
}
if (!attendeesList || attendeesList.length === 0) {
return (
<div className="text-center py-8 text-base-content/70">
<Icon icon="heroicons:user-group" className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No attendees yet</p>
<button
className="btn btn-sm btn-outline mt-4"
onClick={refreshAttendees}
>
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
Refresh
</button>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100vh-16rem)]">
<div className="flex flex-col gap-4 mb-4">
{/* Search and Actions Row */}
<div className="flex justify-between items-center gap-4">
<div className="flex-1 flex gap-2">
<div className="join flex-1">
<div className="join-item bg-base-200 flex items-center px-3">
<Icon icon="heroicons:magnifying-glass" className="h-5 w-5 opacity-70" />
</div>
<input
type="text"
placeholder="Search attendees..."
className="input input-bordered join-item w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2">
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshAttendees}
>
<Icon icon="heroicons:arrow-path" className="h-4 w-4" />
Refresh
</button>
<button
className="btn btn-primary btn-sm gap-2"
onClick={downloadAttendeesCSV}
>
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
Download CSV
</button>
</div>
</div>
{/* Stats Row */}
<div className="flex justify-between items-center">
<div className="text-sm opacity-70">
Total Attendees: {attendeesList.length}
</div>
{searchTerm && (
<div className="text-sm opacity-70">
Showing: {filteredAttendees.length} matches
</div>
)}
</div>
</div>
{/* Table with pagination */}
<div className="overflow-x-auto flex-1">
<table className="table table-zebra w-full">
<thead className="sticky top-0 bg-base-100">
<tr>
<th className="bg-base-100">Name</th>
<th className="bg-base-100">Email</th>
<th className="bg-base-100">PID</th>
<th className="bg-base-100">Member ID</th>
<th className="bg-base-100">Member Type</th>
<th className="bg-base-100">Graduation Year</th>
<th className="bg-base-100">Major</th>
<th className="bg-base-100">Check-in Time</th>
<th className="bg-base-100">Food Choice</th>
<th className="bg-base-100">Points</th>
</tr>
</thead>
<tbody>
{paginatedAttendees.map((attendee, index) => {
const user = users.get(attendee.user_id);
let checkInTime = '';
try {
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
} catch (e) {
checkInTime = attendee.time_checked_in || 'N/A';
}
return (
<tr key={`${attendee.user_id}-${index}`}>
<td><HighlightText text={user?.name || `User ${attendee.user_id}`} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.email || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.pid || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.member_id || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.member_type || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.graduation_year || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.major || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={checkInTime} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={attendee.food || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={attendee.points_earned || 'N/A'} searchTerms={processedSearchTerms} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{paginationInfo.totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasPrevPage}
onClick={() => setCurrentPage(1)}
>
«
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasPrevPage}
onClick={() => setCurrentPage(p => p - 1)}
>
</button>
<button className="join-item btn btn-sm">
Page {currentPage} of {paginationInfo.totalPages}
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasNextPage}
onClick={() => setCurrentPage(p => p + 1)}
>
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasNextPage}
onClick={() => setCurrentPage(paginationInfo.totalPages)}
>
»
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,934 @@
import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
import { Icon } from "@iconify/react";
import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Update } from "../../../scripts/pocketbase/Update";
import { FileManager } from "../../../scripts/pocketbase/FileManager";
import { SendLog } from "../../../scripts/pocketbase/SendLog";
import FilePreview from "../universal/FilePreview";
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import toast from "react-hot-toast";
// Note: Date conversion is now handled automatically by the Get and Update classes.
// When fetching events, UTC dates are converted to local time by the Get class.
// When saving events, local dates are converted back to UTC by the Update class.
// For datetime-local inputs, we format dates without seconds (YYYY-MM-DDThh:mm).
// Extended Event interface with optional created and updated fields
interface Event extends Omit<SchemaEvent, 'created' | 'updated'> {
created?: string;
updated?: string;
}
// Extend Window interface
declare global {
interface Window {
showLoading?: () => void;
hideLoading?: () => void;
lastCacheUpdate?: number;
fetchEvents?: () => void;
}
}
interface EventEditorProps {
onEventSaved?: () => void;
}
// Memoize the FilePreview component
const MemoizedFilePreview = memo(FilePreview);
// Define EventForm props interface
interface EventFormProps {
event: Event | null;
setEvent: (field: keyof Event, value: any) => void;
selectedFiles: Map<string, File>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Map<string, File>>>;
filesToDelete: Set<string>;
setFilesToDelete: React.Dispatch<React.SetStateAction<Set<string>>>;
handlePreviewFile: (url: string, filename: string) => void;
isSubmitting: boolean;
fileManager: FileManager;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
onCancel: () => void;
}
// Create a memoized form component
const EventForm = memo(({
event,
setEvent,
selectedFiles,
setSelectedFiles,
filesToDelete,
setFilesToDelete,
handlePreviewFile,
isSubmitting,
fileManager,
onSubmit,
onCancel
}: EventFormProps): React.ReactElement => {
const handleChange = (field: keyof Event, value: any) => {
setEvent(field, value);
};
return (
<div id="editFormSection">
<h3 className="font-bold text-lg mb-4" id="editModalTitle">
{event?.id ? 'Edit Event' : 'Add New Event'}
</h3>
<form
id="editEventForm"
className="space-y-4"
onSubmit={onSubmit}
>
<input type="hidden" id="editEventId" name="editEventId" value={event?.id || ''} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Event Name */}
<div className="form-control">
<label className="label">
<span className="label-text">Event Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
name="editEventName"
className="input input-bordered"
value={event?.event_name || ""}
onChange={(e) => handleChange('event_name', e.target.value)}
required
/>
</div>
{/* Event Code */}
<div className="form-control">
<label className="label">
<span className="label-text">Event Code</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
name="editEventCode"
className="input input-bordered"
value={event?.event_code || ""}
onChange={(e) => handleChange('event_code', e.target.value)}
required
/>
</div>
{/* Location */}
<div className="form-control">
<label className="label">
<span className="label-text">Location</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
name="editEventLocation"
className="input input-bordered"
value={event?.location || ""}
onChange={(e) => handleChange('location', e.target.value)}
required
/>
</div>
{/* Points to Reward */}
<div className="form-control">
<label className="label">
<span className="label-text">Points to Reward</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="number"
name="editEventPoints"
className="input input-bordered"
value={event?.points_to_reward || 0}
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
min="0"
required
/>
</div>
{/* Start Date */}
<div className="form-control">
<label className="label">
<span className="label-text">Start Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
name="editEventStartDate"
className="input input-bordered"
value={event?.start_date || ""}
onChange={(e) => handleChange('start_date', e.target.value)}
required
/>
</div>
{/* End Date */}
<div className="form-control">
<label className="label">
<span className="label-text">End Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
name="editEventEndDate"
className="input input-bordered"
value={event?.end_date || ""}
onChange={(e) => handleChange('end_date', e.target.value)}
required
/>
</div>
</div>
{/* Description */}
<div className="form-control">
<label className="label">
<span className="label-text">Description</span>
<span className="label-text-alt text-error">*</span>
</label>
<textarea
name="editEventDescription"
className="textarea textarea-bordered"
value={event?.event_description || ""}
onChange={(e) => handleChange('event_description', e.target.value)}
rows={3}
required
></textarea>
</div>
{/* Files */}
<div className="form-control">
<label className="label">
<span className="label-text">Upload Files</span>
</label>
<input
type="file"
name="editEventFiles"
onChange={(e) => {
if (e.target.files) {
const newFiles = new Map(selectedFiles);
const rejectedFiles: { name: string, reason: string }[] = [];
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
Array.from(e.target.files).forEach(file => {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2);
rejectedFiles.push({
name: file.name,
reason: `exceeds size limit (${fileSizeMB}MB > 200MB)`
});
return;
}
// Validate file type
const validation = fileManager.validateFileType(file);
if (!validation.valid) {
rejectedFiles.push({
name: file.name,
reason: validation.reason || 'unsupported file type'
});
return;
}
// Only add valid files
newFiles.set(file.name, file);
});
// Show error for rejected files
if (rejectedFiles.length > 0) {
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
toast.error(errorMessage);
}
setSelectedFiles(newFiles);
}
}}
className="file-input file-input-bordered"
multiple
/>
<div className="mt-4 space-y-2">
{/* New Files */}
{Array.from(selectedFiles.entries()).map(([name, file]) => (
<div key={name} className="flex items-center justify-between p-2 bg-base-200 rounded-lg">
<span className="truncate">{name}</span>
<div className="flex gap-2">
<div className="badge badge-primary">New</div>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
onClick={() => {
const updatedFiles = new Map(selectedFiles);
updatedFiles.delete(name);
setSelectedFiles(updatedFiles);
}}
>
<Icon icon="heroicons:x-circle" className="h-4 w-4" />
</button>
</div>
</div>
))}
{/* Current Files */}
{event?.files && event.files.length > 0 && (
<>
<div className="divider">Current Files</div>
{event.files.map((filename) => (
<div key={filename} className={`flex items-center justify-between p-2 bg-base-200 rounded-lg${filesToDelete.has(filename) ? " opacity-50" : ""}`}>
<span className="truncate">{filename}</span>
<div className="flex gap-2">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => {
if (event?.id) {
handlePreviewFile(
fileManager.getFileUrl("events", event.id, filename),
filename
);
}
}}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
</button>
<div className="text-error">
{filesToDelete.has(filename) ? (
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => {
const newFilesToDelete = new Set(filesToDelete);
newFilesToDelete.delete(filename);
setFilesToDelete(newFilesToDelete);
}}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
) : (
<button
type="button"
className="btn btn-ghost btn-xs text-error"
onClick={() => {
const newFilesToDelete = new Set(filesToDelete);
newFilesToDelete.add(filename);
setFilesToDelete(newFilesToDelete);
}}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
))}
</>
)}
</div>
</div>
{/* Published */}
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="editEventPublished"
className="toggle"
checked={event?.published || false}
onChange={(e) => handleChange('published', e.target.checked)}
/>
<span className="label-text">Publish Event</span>
</label>
<label className="label">
<span className="label-text-alt text-info">
This has to be clicked if you want to make this event available
to the public
</span>
</label>
</div>
{/* Has Food */}
<div className="form-control">
<label className="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="editEventHasFood"
className="toggle"
checked={event?.has_food || false}
onChange={(e) => handleChange('has_food', e.target.checked)}
/>
<span className="label-text">Has Food</span>
</label>
<label className="label">
<span className="label-text-alt text-info">
Check this if food will be provided at the event
</span>
</label>
</div>
{/* Action Buttons */}
<div className="modal-action mt-6">
<button
type="submit"
className={`btn btn-primary ${isSubmitting ? 'loading' : ''}`}
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
className="btn"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
</div>
</form>
</div>
);
});
// Add new interfaces for change tracking
interface EventChanges {
event_name?: string;
event_description?: string;
event_code?: string;
location?: string;
points_to_reward?: number;
start_date?: string;
end_date?: string;
published?: boolean;
has_food?: boolean;
}
interface FileChanges {
added: Map<string, File>;
deleted: Set<string>;
unchanged: string[];
}
// Add queue management for large datasets
class UploadQueue {
private queue: Array<() => Promise<void>> = [];
private processing = false;
private readonly BATCH_SIZE = 5;
async add(task: () => Promise<void>) {
this.queue.push(task);
if (!this.processing) {
await this.process();
}
}
private async process() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
try {
while (this.queue.length > 0) {
const batch = this.queue.splice(0, this.BATCH_SIZE);
await Promise.all(batch.map(task => task()));
}
} finally {
this.processing = false;
}
}
}
// Add change tracking utility
class ChangeTracker {
private initialState: Event | null = null;
private currentState: Event | null = null;
private fileChanges: FileChanges = {
added: new Map(),
deleted: new Set(),
unchanged: []
};
initialize(event: Event | null) {
this.initialState = event ? { ...event } : null;
this.currentState = event ? { ...event } : null;
this.fileChanges = {
added: new Map(),
deleted: new Set(),
unchanged: event?.files || []
};
}
trackChange(field: keyof Event, value: any) {
if (!this.currentState) {
this.currentState = {} as Event;
}
(this.currentState as any)[field] = value;
}
trackFileChange(added: Map<string, File>, deleted: Set<string>) {
this.fileChanges.added = added;
this.fileChanges.deleted = deleted;
if (this.initialState?.files) {
this.fileChanges.unchanged = this.initialState.files.filter(
file => !deleted.has(file)
);
}
}
getChanges(): EventChanges {
if (!this.initialState || !this.currentState) return {};
const changes: EventChanges = {};
const fields: (keyof EventChanges)[] = [
'event_name',
'event_description',
'event_code',
'location',
'points_to_reward',
'start_date',
'end_date',
'published',
'has_food'
];
for (const field of fields) {
if (this.initialState[field] !== this.currentState[field]) {
(changes[field] as any) = this.currentState[field];
}
}
return changes;
}
getFileChanges(): FileChanges {
return this.fileChanges;
}
hasChanges(): boolean {
return Object.keys(this.getChanges()).length > 0 ||
this.fileChanges.added.size > 0 ||
this.fileChanges.deleted.size > 0;
}
}
// Add new interfaces for loading states
interface LoadingState {
isLoading: boolean;
error: string | null;
timeoutId: NodeJS.Timeout | null;
}
// Add loading spinner component
const LoadingSpinner = memo(() => (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="loading loading-spinner loading-lg text-primary"></div>
<p className="text-base-content/70">Loading event data...</p>
</div>
));
// Add error display component
const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="text-error">
<Icon icon="heroicons:x-circle" className="h-12 w-12" />
</div>
<p className="text-error font-medium">{error}</p>
<button className="btn btn-error btn-sm" onClick={onRetry}>
Try Again
</button>
</div>
));
// Modify EventEditor component
export default function EventEditor({ onEventSaved }: EventEditorProps) {
// State for form data and UI
const [event, setEvent] = useState<Event>({
id: "",
created: "",
updated: "",
event_name: "",
event_description: "",
event_code: "",
location: "",
files: [],
points_to_reward: 0,
start_date: "",
end_date: "",
published: false,
has_food: false
});
const [previewUrl, setPreviewUrl] = useState("");
const [previewFilename, setPreviewFilename] = useState("");
const [showPreview, setShowPreview] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Map<string, File>>(new Map());
const [filesToDelete, setFilesToDelete] = useState<Set<string>>(new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Memoize service instances
const services = useMemo(() => ({
get: Get.getInstance(),
auth: Authentication.getInstance(),
update: Update.getInstance(),
fileManager: FileManager.getInstance(),
sendLog: SendLog.getInstance()
}), []);
// Handle field changes
const handleFieldChange = useCallback((field: keyof Event, value: any) => {
setEvent(prev => {
const newEvent = { ...prev, [field]: value };
// Only set hasUnsavedChanges if the value actually changed
if (prev[field] !== value) {
setHasUnsavedChanges(true);
}
return newEvent;
});
}, []);
// Initialize event data
const initializeEventData = useCallback(async (eventId: string) => {
try {
if (eventId) {
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
// Fetch fresh event data
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
if (!eventData) {
throw new Error("Event not found");
}
// Ensure dates are properly formatted for datetime-local input
if (eventData.start_date) {
// Convert to Date object first to ensure proper formatting
const startDate = new Date(eventData.start_date);
eventData.start_date = Get.formatLocalDate(startDate, false);
}
if (eventData.end_date) {
// Convert to Date object first to ensure proper formatting
const endDate = new Date(eventData.end_date);
eventData.end_date = Get.formatLocalDate(endDate, false);
}
// Ensure all fields are properly set
setEvent({
id: eventData.id || '',
created: eventData.created || '',
updated: eventData.updated || '',
event_name: eventData.event_name || '',
event_description: eventData.event_description || '',
event_code: eventData.event_code || '',
location: eventData.location || '',
files: eventData.files || [],
points_to_reward: eventData.points_to_reward || 0,
start_date: eventData.start_date || '',
end_date: eventData.end_date || '',
published: eventData.published || false,
has_food: eventData.has_food || false
});
console.log("Event data loaded successfully:", eventData);
} else {
setEvent({
id: '',
created: '',
updated: '',
event_name: '',
event_description: '',
event_code: '',
location: '',
files: [],
points_to_reward: 0,
start_date: '',
end_date: '',
published: false,
has_food: false
});
}
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
setHasUnsavedChanges(false);
} catch (error) {
console.error("Failed to initialize event data:", error);
toast.error("Failed to load event data. Please try again.");
}
}, [services.get]);
// Expose initializeEventData to window
useEffect(() => {
(window as any).openEditModal = async (event?: Event) => {
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (!modal) return;
try {
if (event?.id) {
// Always fetch fresh data from PocketBase for the event
await initializeEventData(event.id);
} else {
// Reset form for new event
await initializeEventData('');
}
modal.showModal();
} catch (error) {
console.error("Failed to open edit modal:", error);
toast.error("Failed to open edit modal. Please try again.");
}
};
return () => {
delete (window as any).openEditModal;
};
}, [initializeEventData]);
// Handler functions
const handlePreviewFile = useCallback((url: string, filename: string) => {
setPreviewUrl(url);
setPreviewFilename(filename);
setShowPreview(true);
}, []);
const handleModalClose = useCallback(() => {
if (hasUnsavedChanges) {
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?');
if (!confirmed) return;
}
setEvent({
id: "",
created: "",
updated: "",
event_name: "",
event_description: "",
event_code: "",
location: "",
files: [],
points_to_reward: 0,
start_date: "",
end_date: "",
published: false,
has_food: false
});
setSelectedFiles(new Map());
setFilesToDelete(new Set());
setShowPreview(false);
setPreviewUrl("");
setPreviewFilename("");
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
if (modal) modal.close();
}, [hasUnsavedChanges]);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitting) return;
try {
setIsSubmitting(true);
window.showLoading?.();
const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement;
const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement;
if (submitButton) {
submitButton.disabled = true;
submitButton.classList.add("btn-disabled");
}
if (cancelButton) cancelButton.disabled = true;
// Get form data
const formData = new FormData(e.currentTarget);
// Create updated event object
const updatedEvent: Omit<Event, 'created' | 'updated'> = {
id: event.id,
event_name: formData.get("editEventName") as string,
event_description: formData.get("editEventDescription") as string,
event_code: formData.get("editEventCode") as string,
location: formData.get("editEventLocation") as string,
files: event.files || [],
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
start_date: formData.get("editEventStartDate") as string,
end_date: formData.get("editEventEndDate") as string,
published: formData.get("editEventPublished") === "on",
has_food: formData.get("editEventHasFood") === "on"
};
// Log the update attempt
await services.sendLog.send(
"update",
"event",
`${event.id ? "Updating" : "Creating"} event: ${updatedEvent.event_name} (${event.id || "new"})`
);
if (event.id) {
// We're updating an existing event
// First, update the event data without touching files
const { files, ...cleanPayload } = updatedEvent;
await services.update.updateFields<Event>(
Collections.EVENTS,
event.id,
cleanPayload
);
// Handle file operations
if (filesToDelete.size > 0 || selectedFiles.size > 0) {
// Get the current event with its files
const currentEvent = await services.get.getOne<Event>(Collections.EVENTS, event.id);
let currentFiles = currentEvent?.files || [];
// 1. Remove files marked for deletion
if (filesToDelete.size > 0) {
console.log(`Removing ${filesToDelete.size} files from event ${event.id}`);
currentFiles = currentFiles.filter(file => !filesToDelete.has(file));
// Update the files field first to remove deleted files
await services.update.updateFields<Event>(
Collections.EVENTS,
event.id,
{ files: currentFiles }
);
}
// 2. Add new files one by one to preserve existing ones
if (selectedFiles.size > 0) {
console.log(`Adding ${selectedFiles.size} new files to event ${event.id}`);
// Convert Map to array of File objects
const newFiles = Array.from(selectedFiles.values());
// Use FileManager to upload each file individually
for (const file of newFiles) {
// Use the FileManager to upload this file
await services.fileManager.uploadFile(
Collections.EVENTS,
event.id,
'files',
file,
true // Set append mode to true to preserve existing files
);
}
}
}
// Get the final updated event with all changes
const savedEvent = await services.get.getOne<Event>(Collections.EVENTS, event.id);
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
// Update the window object with the latest event data
const eventDataId = `event_${event.id}`;
if ((window as any)[eventDataId]) {
(window as any)[eventDataId] = savedEvent;
}
toast.success("Event updated successfully!");
// Call the onEventSaved callback if provided
if (onEventSaved) onEventSaved();
// Close the modal
handleModalClose();
} else {
// We're creating a new event
// Create the event first without files
const { files, ...cleanPayload } = updatedEvent;
const newEvent = await services.update.create<Event>(
Collections.EVENTS,
cleanPayload
);
// Then upload files if any
if (selectedFiles.size > 0 && newEvent?.id) {
console.log(`Adding ${selectedFiles.size} files to new event ${newEvent.id}`);
// Convert Map to array of File objects
const newFiles = Array.from(selectedFiles.values());
// Upload files to the new event
for (const file of newFiles) {
await services.fileManager.uploadFile(
Collections.EVENTS,
newEvent.id,
'files',
file,
true // Set append mode to true
);
}
}
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
toast.success("Event created successfully!");
// Call the onEventSaved callback if provided
if (onEventSaved) onEventSaved();
// Close the modal
handleModalClose();
}
// Refresh events list if available
if (window.fetchEvents) window.fetchEvents();
} catch (error) {
console.error("Failed to save event:", error);
toast.error(`Failed to save event: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSubmitting(false);
window.hideLoading?.();
}
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, handleModalClose]);
return (
<dialog id="editEventModal" className="modal">
{showPreview ? (
<div className="modal-box max-w-4xl">
<div className="flex justify-between items-center mb-4">
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowPreview(false)}
>
Close
</button>
</div>
<div className="relative">
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
</div>
</div>
) : (
<div className="modal-box max-w-2xl">
<EventForm
event={event}
setEvent={handleFieldChange}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
filesToDelete={filesToDelete}
setFilesToDelete={setFilesToDelete}
handlePreviewFile={handlePreviewFile}
isSubmitting={isSubmitting}
fileManager={services.fileManager}
onSubmit={handleSubmit}
onCancel={handleModalClose}
/>
</div>
)}
</dialog>
);
}

View file

@ -0,0 +1,337 @@
---
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
import { Collections } from "../../schemas/pocketbase/schema";
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { EventRequestFormPreviewModal } from "./Officer_EventRequestForm/EventRequestFormPreview";
// Import the EventRequest type from UserEventRequests to ensure consistency
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
// Use the imported type
type EventRequest = UserEventRequest;
// Get instances
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Initialize variables for user's submissions
let userEventRequests: EventRequest[] = [];
let error: string | null = null;
// Fetch user's event request submissions if authenticated
// This provides initial data for server-side rendering
// Client-side will use IndexedDB for data management
if (auth.isAuthenticated()) {
try {
const userId = auth.getUserId();
if (userId) {
userEventRequests = await get.getAll<EventRequest>(
Collections.EVENT_REQUESTS,
`requested_user="${userId}"`,
"-created"
);
}
} catch (err) {
console.error("Failed to fetch user event requests:", err);
error = "Failed to load your event requests. Please try again later.";
}
}
---
<div class="w-full max-w-6xl mx-auto py-8 px-4">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
<p class="text-gray-300 mb-4">
Submit your event request at least 6 weeks before your event. After
submitting, please notify PR and/or Coordinators in the #-events
Slack channel.
</p>
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
<p class="font-medium mb-2">This form includes sections for:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>PR Materials (if needed)</li>
<li>Event Details</li>
<li>TAP Form Information</li>
<li>AS Funding (if needed)</li>
</ul>
<p class="mt-3">
Your progress is automatically saved as you fill out the form.
</p>
</div>
</div>
<!-- Tabs -->
<div class="tabs tabs-boxed mb-6">
<a class="tab tab-lg tab-active" id="form-tab">Submit Event Request</a>
<a class="tab tab-lg" id="submissions-tab">View Your Submissions</a>
</div>
<!-- Form Tab Content -->
<div
id="form-content"
class="bg-base-200 rounded-lg shadow-xl overflow-hidden"
>
<div class="p-6">
<EventRequestForm client:load />
</div>
</div>
<!-- Submissions Tab Content -->
<div id="submissions-content" class="hidden">
<div class="bg-base-200 rounded-lg shadow-xl overflow-hidden p-6">
<h2 class="text-2xl font-bold text-white mb-4">
Your Event Request Submissions
</h2>
{
error && (
<div class="alert alert-error mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 stroke-current shrink-0"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
)
}
{
!error && (
<UserEventRequests
client:load
eventRequests={userEventRequests}
/>
)
}
</div>
</div>
</div>
<style is:global>
/* Ensure the modal container is always visible */
#event-request-preview-modal-container {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
overflow: auto !important;
margin: 0 !important;
padding: 0 !important;
}
/* Style for the modal backdrop */
#event-request-preview-modal-container > div > div:first-child {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(8px) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
overflow: auto !important;
}
/* Style for the modal content */
#event-request-preview-modal-container > div > div > div {
z-index: 100000 !important;
position: relative !important;
max-width: 90vw !important;
width: 100% !important;
max-height: 90vh !important;
overflow: auto !important;
margin: 2rem !important;
}
</style>
<!-- Add the modal component -->
<EventRequestFormPreviewModal client:load />
<div class="dashboard-section hidden" id="eventRequestFormSection">
<!-- ... existing code ... -->
</div>
<script is:inline>
// Define the global function immediately to ensure it's available
window.showEventRequestFormPreview = function (formData) {
console.log(
"Global showEventRequestFormPreview called with data",
formData
);
// Remove any elements that might be obstructing the view
const removeObstructions = () => {
// Find any elements with high z-index that might be obstructing
document.querySelectorAll('[style*="z-index"]').forEach((el) => {
if (
el.id !== "event-request-preview-modal-container" &&
!el.closest("#event-request-preview-modal-container")
) {
// Store original z-index to restore later
if (!el.dataset.originalZIndex) {
el.dataset.originalZIndex = el.style.zIndex;
}
// Temporarily lower z-index
el.style.zIndex = "0";
}
});
};
// Create a custom event to trigger the preview
const event = new CustomEvent("showEventRequestPreviewModal", {
detail: { formData },
});
// Remove obstructions before showing modal
removeObstructions();
// Dispatch event to show modal
document.dispatchEvent(event);
console.log("showEventRequestPreviewModal event dispatched");
// Ensure modal container is visible
setTimeout(() => {
const modalContainer = document.getElementById(
"event-request-preview-modal-container"
);
if (modalContainer) {
modalContainer.style.zIndex = "99999";
modalContainer.style.position = "fixed";
modalContainer.style.top = "0";
modalContainer.style.left = "0";
modalContainer.style.width = "100vw";
modalContainer.style.height = "100vh";
modalContainer.style.overflow = "auto";
modalContainer.style.margin = "0";
modalContainer.style.padding = "0";
// Force body to allow scrolling
document.body.style.overflow = "auto";
// Ensure the modal content is properly sized
const modalContent =
modalContainer.querySelector("div > div > div");
if (modalContent) {
modalContent.style.maxWidth = "90vw";
modalContent.style.width = "100%";
modalContent.style.maxHeight = "90vh";
modalContent.style.overflow = "auto";
modalContent.style.margin = "2rem";
}
}
}, 100);
};
</script>
<script>
// Import the DataSyncService for client-side use
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { Collections } from "../../schemas/pocketbase/schema";
import { Authentication } from "../../scripts/pocketbase/Authentication";
// Tab switching logic
document.addEventListener("DOMContentLoaded", async () => {
// Initialize DataSyncService for client-side
const dataSync = DataSyncService.getInstance();
const auth = Authentication.getInstance();
// Prefetch data into IndexedDB if authenticated
if (auth.isAuthenticated()) {
try {
const userId = auth.getUserId();
if (userId) {
// Force sync to ensure we have the latest data
await dataSync.syncCollection(
Collections.EVENT_REQUESTS,
`requested_user="${userId}"`,
"-created"
);
console.log(
"Initial data sync complete for user event requests"
);
}
} catch (err) {
console.error("Error during initial data sync:", err);
}
}
const formTab = document.getElementById("form-tab");
const submissionsTab = document.getElementById("submissions-tab");
const formContent = document.getElementById("form-content");
const submissionsContent = document.getElementById(
"submissions-content"
);
// Function to switch tabs
const switchTab = (
activeTab: HTMLElement,
activeContent: HTMLElement,
inactiveTab: HTMLElement,
inactiveContent: HTMLElement
) => {
// Update tab classes
activeTab.classList.add("tab-active");
inactiveTab.classList.remove("tab-active");
// Show/hide content
activeContent.classList.remove("hidden");
inactiveContent.classList.add("hidden");
// Dispatch event to refresh submissions when switching to submissions tab
if (activeTab.id === "submissions-tab") {
// Dispatch a custom event that the UserEventRequests component listens for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
};
// Add click event listeners to tabs
formTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && submissionsTab) {
switchTab(
formTab,
formContent,
submissionsTab,
submissionsContent
);
}
});
submissionsTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && formTab) {
switchTab(
submissionsTab,
submissionsContent,
formTab,
formContent
);
}
});
// Listen for visibility changes
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
// Dispatch custom event that components can listen for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
});
});
</script>

View file

@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
import type { EventRequestFormData } from './EventRequestForm';
import InvoiceBuilder from './InvoiceBuilder';
import type { InvoiceData } from './InvoiceBuilder';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
interface ASFundingSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
const [invoiceFile, setInvoiceFile] = useState<File | null>(formData.invoice);
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
// Handle single invoice file upload (for backward compatibility)
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
setInvoiceFile(file);
onDataChange({ invoice: file });
}
};
// Handle multiple invoice files upload
const handleMultipleInvoiceFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const files = Array.from(e.target.files) as File[];
// Check file sizes
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit per file
const oversizedFiles = files.filter(file => file.size > MAX_FILE_SIZE);
if (oversizedFiles.length > 0) {
toast.error(`Some files exceed the 10MB size limit: ${oversizedFiles.map(f => f.name).join(', ')}`);
return;
}
// Update state with new files
const updatedFiles = [...invoiceFiles, ...files];
setInvoiceFiles(updatedFiles);
onDataChange({ invoice_files: updatedFiles });
// Also set the first file as the main invoice file for backward compatibility
if (files.length > 0 && !formData.invoice) {
setInvoiceFile(files[0]);
onDataChange({ invoice: files[0] });
}
toast.success(`Added ${files.length} file${files.length > 1 ? 's' : ''} successfully`);
}
};
// Remove an invoice file
const handleRemoveInvoiceFile = (index: number) => {
const updatedFiles = [...invoiceFiles];
const removedFileName = updatedFiles[index].name;
updatedFiles.splice(index, 1);
setInvoiceFiles(updatedFiles);
onDataChange({ invoice_files: updatedFiles });
// Update the main invoice file if needed
if (invoiceFile && invoiceFile.name === removedFileName) {
const newMainFile = updatedFiles.length > 0 ? updatedFiles[0] : null;
setInvoiceFile(newMainFile);
onDataChange({ invoice: newMainFile });
}
toast.success(`Removed ${removedFileName}`);
};
// Handle invoice data change
const handleInvoiceDataChange = (invoiceData: InvoiceData) => {
// Update the invoiceData in the form
onDataChange({ invoiceData });
// For backward compatibility, create a properly formatted JSON string
const jsonFormat = {
items: invoiceData.items.map(item => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice
})),
tax: invoiceData.taxAmount,
tip: invoiceData.tipAmount,
total: invoiceData.total,
vendor: invoiceData.vendor
};
// For backward compatibility, still update the itemized_invoice field
// but with a more structured format that's easier to parse if needed
const itemizedText = JSON.stringify(jsonFormat, null, 2);
// Update the itemized_invoice field for backward compatibility
onDataChange({ itemized_invoice: itemizedText });
};
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 text-primary">AS Funding Information</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<div className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<p className="text-sm">
Please make sure the restaurant is a valid AS Funding food vendor! An invoice can be an unofficial receipt. Just make sure that the restaurant name and location, desired pickup or delivery date and time, all the items ordered plus their prices, discount/fees/tax/tip, and total are on the invoice! We don't recommend paying out of pocket because reimbursements can be a hassle when you're not a Principal Member.
</p>
</div>
</div>
{/* Invoice Builder Instructions */}
<motion.div variants={itemVariants} className="bg-base-300/50 p-4 rounded-lg mb-6">
<h3 className="font-bold text-lg mb-2">How to Use the Invoice Builder</h3>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>Enter the vendor/restaurant name in the field provided.</li>
<li>Add each item from your invoice by filling in the description, quantity, and unit price, then click "Add Item".</li>
<li>The subtotal, tax, and tip will be calculated automatically based on the tax rate and tip percentage.</li>
<li>You can adjust the tax rate (default is 7.75% for San Diego) and tip percentage as needed.</li>
<li>Remove items by clicking the "X" button next to each item.</li>
<li>Upload your actual invoice file (receipt, screenshot, etc.) using the file upload below.</li>
</ol>
<p className="text-sm mt-3 text-warning">Note: The invoice builder helps you itemize your expenses for AS funding. You must still upload the actual invoice file.</p>
</motion.div>
{/* Invoice Builder */}
<InvoiceBuilder
invoiceData={formData.invoiceData}
onChange={handleInvoiceDataChange}
/>
{/* Invoice file upload */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">
Upload your invoice files (receipts, screenshots, etc.)
</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="file"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleMultipleInvoiceFilesChange}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
multiple
required={invoiceFiles.length === 0}
/>
{invoiceFiles.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">Uploaded files:</p>
<div className="space-y-2">
{invoiceFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-300/30 p-2 rounded">
<span className="text-sm truncate max-w-[80%]">{file.name}</span>
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => handleRemoveInvoiceFile(index)}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-gray-400 mt-2">
Official food invoices will be required 2 weeks before the start of your event. Please use the following naming format: EventName_OrderLocation_DateOfEvent (i.e. QPWorkathon#1_PapaJohns_01/06/2025)
</p>
</motion.div>
<motion.div
variants={itemVariants}
className="alert alert-warning"
>
<div>
<h3 className="font-bold">Important Note</h3>
<div className="text-sm">
AS Funding requests must be submitted at least 6 weeks before your event. Please check the Funding Guide or the Google Calendar for the funding request deadlines.
</div>
</div>
</motion.div>
</div>
);
};
export default ASFundingSection;

View file

@ -0,0 +1,147 @@
import React from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
interface EventDetailsSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onDataChange }) => {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 text-primary">Event Details</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you.
</p>
</div>
{/* Event Name */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Event Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.name}
onChange={(e) => onDataChange({ name: e.target.value })}
placeholder="Enter event name"
required
/>
</motion.div>
{/* Event Description */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Event Description</span>
<span className="label-text-alt text-error">*</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px] mt-2"
value={formData.event_description}
onChange={(e) => onDataChange({ event_description: e.target.value })}
placeholder="Provide a detailed description of your event"
rows={4}
required
/>
</motion.div>
{/* Event Start Date */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Event Start Date & Time</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.start_date_time}
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
required
/>
</motion.div>
{/* Event End Date */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Event End Date & Time</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.end_date_time}
onChange={(e) => onDataChange({ end_date_time: e.target.value })}
required
/>
</motion.div>
{/* Event Location */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Event Location</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.location}
onChange={(e) => onDataChange({ location: e.target.value })}
placeholder="Enter event location"
required
/>
</motion.div>
{/* Room Booking */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Do you/will you have a room booking for this event?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.will_or_have_room_booking === true}
onChange={() => onDataChange({ will_or_have_room_booking: true })}
required
/>
<span>Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.will_or_have_room_booking === false}
onChange={() => onDataChange({ will_or_have_room_booking: false })}
required
/>
<span>No</span>
</label>
</div>
</motion.div>
</div>
);
};
export default EventDetailsSection;

View file

@ -0,0 +1,821 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import toast from 'react-hot-toast';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { EventRequestStatus } from '../../../schemas/pocketbase';
// Form sections
import PRSection from './PRSection';
import EventDetailsSection from './EventDetailsSection';
import TAPFormSection from './TAPFormSection';
import ASFundingSection from './ASFundingSection';
import EventRequestFormPreview from './EventRequestFormPreview';
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
// Animation variants
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Form data interface - based on the schema EventRequest but with form-specific fields
export interface EventRequestFormData {
// Fields from EventRequest
name: string;
location: string;
start_date_time: string;
end_date_time: string;
event_description: string;
flyers_needed: boolean;
photography_needed: boolean;
as_funding_required: boolean;
food_drinks_being_served: boolean;
itemized_invoice?: string;
status?: string;
created_by?: string;
id?: string;
created?: string;
updated?: string;
// Additional form-specific fields
flyer_type: string[];
other_flyer_type: string;
flyer_advertising_start_date: string;
flyer_additional_requests: string;
required_logos: string[];
other_logos: File[]; // Form uses File objects, schema uses strings
advertising_format: string;
will_or_have_room_booking: boolean;
expected_attendance: number;
room_booking: File | null;
invoice: File | null;
invoice_files: File[];
invoiceData: InvoiceData;
needs_graphics?: boolean | null;
needs_as_funding?: boolean | null;
formReviewed?: boolean; // Track if the form has been reviewed
}
const EventRequestForm: React.FC = () => {
const [currentStep, setCurrentStep] = useState<number>(1);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Initialize form data
const [formData, setFormData] = useState<EventRequestFormData>({
name: '',
location: '',
start_date_time: '',
end_date_time: '',
event_description: '',
flyers_needed: false,
flyer_type: [],
other_flyer_type: '',
flyer_advertising_start_date: '',
flyer_additional_requests: '',
photography_needed: false,
required_logos: [],
other_logos: [],
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null,
as_funding_required: false,
food_drinks_being_served: false,
itemized_invoice: '',
invoice: null,
invoice_files: [], // Initialize empty array for multiple invoice files
needs_graphics: null,
needs_as_funding: false,
invoiceData: {
items: [],
subtotal: 0,
taxRate: 7.75, // Default tax rate for San Diego
taxAmount: 0,
tipPercentage: 15, // Default tip percentage
tipAmount: 0,
total: 0,
vendor: ''
},
formReviewed: false // Initialize as false
});
// Save form data to localStorage
useEffect(() => {
const formDataToSave = { ...formData };
// Remove file objects before saving to localStorage
const dataToStore = {
...formDataToSave,
other_logos: [],
room_booking: null,
invoice: null,
invoice_files: []
};
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
// Also update the preview data
window.dispatchEvent(new CustomEvent('formDataUpdated', {
detail: { formData: formDataToSave }
}));
}, [formData]);
// Load form data from localStorage on initial load
useEffect(() => {
const savedData = localStorage.getItem('eventRequestFormData');
if (savedData) {
try {
const parsedData = JSON.parse(savedData);
setFormData(prevData => ({
...prevData,
...parsedData
}));
} catch (e) {
console.error('Error parsing saved form data:', e);
}
}
}, []);
// Handle form section data changes
const handleSectionDataChange = (sectionData: Partial<EventRequestFormData>) => {
setFormData(prevData => ({
...prevData,
...sectionData
}));
};
// Add this function before the handleSubmit function
const resetForm = () => {
setFormData({
name: '',
location: '',
start_date_time: '',
end_date_time: '',
event_description: '',
flyers_needed: false,
flyer_type: [],
other_flyer_type: '',
flyer_advertising_start_date: '',
flyer_additional_requests: '',
photography_needed: false,
required_logos: [],
other_logos: [],
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null,
as_funding_required: false,
food_drinks_being_served: false,
itemized_invoice: '',
invoice: null,
invoice_files: [], // Reset multiple invoice files
needs_graphics: null,
needs_as_funding: false,
invoiceData: {
items: [],
subtotal: 0,
taxRate: 7.75, // Default tax rate for San Diego
taxAmount: 0,
tipPercentage: 15, // Default tip percentage
tipAmount: 0,
total: 0,
vendor: ''
},
formReviewed: false // Reset review status
});
// Reset to first step
setCurrentStep(1);
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Check if the form has been reviewed
if (!formData.formReviewed) {
toast.error('Please review your form before submitting');
return;
}
setIsSubmitting(true);
setError(null);
try {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const fileManager = FileManager.getInstance();
const dataSync = DataSyncService.getInstance();
if (!auth.isAuthenticated()) {
toast.error('You must be logged in to submit an event request');
throw new Error('You must be logged in to submit an event request');
}
// Create the event request record
const userId = auth.getUserId();
if (!userId) {
toast.error('User ID not found');
throw new Error('User ID not found');
}
// Prepare data for submission
const submissionData = {
requested_user: userId,
name: formData.name,
location: formData.location,
start_date_time: new Date(formData.start_date_time).toISOString(),
end_date_time: new Date(formData.end_date_time).toISOString(),
event_description: formData.event_description,
flyers_needed: formData.flyers_needed,
flyer_type: formData.flyer_type,
other_flyer_type: formData.other_flyer_type,
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
flyer_additional_requests: formData.flyer_additional_requests,
photography_needed: formData.photography_needed,
required_logos: formData.required_logos,
advertising_format: formData.advertising_format,
will_or_have_room_booking: formData.will_or_have_room_booking,
expected_attendance: formData.expected_attendance,
as_funding_required: formData.as_funding_required,
food_drinks_being_served: formData.food_drinks_being_served,
// Store the itemized_invoice as a string for backward compatibility
itemized_invoice: formData.itemized_invoice,
// Store the invoice data as a properly formatted JSON object
invoice_data: {
items: formData.invoiceData.items.map(item => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice
})),
tax: formData.invoiceData.taxAmount,
tip: formData.invoiceData.tipAmount,
total: formData.invoiceData.total,
vendor: formData.invoiceData.vendor
},
// Set the initial status to "submitted"
status: EventRequestStatus.SUBMITTED,
};
// Create the record using the Update service
// This will send the data to the server
const record = await update.create('event_request', submissionData);
// Force sync the event requests collection to update IndexedDB
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
// Upload files if they exist
if (formData.other_logos.length > 0) {
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
}
if (formData.room_booking) {
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
}
// Upload multiple invoice files
if (formData.invoice_files && formData.invoice_files.length > 0) {
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
// For backward compatibility, also upload the first file as the main invoice
if (formData.invoice || formData.invoice_files[0]) {
const mainInvoice = formData.invoice || formData.invoice_files[0];
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
}
} else if (formData.invoice) {
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
}
// Clear form data from localStorage
localStorage.removeItem('eventRequestFormData');
// Keep success toast for form submission since it's a user action
toast.success('Event request submitted successfully!');
// Reset form
resetForm();
// Switch to the submissions tab
const submissionsTab = document.getElementById('submissions-tab');
if (submissionsTab) {
submissionsTab.click();
}
} catch (error) {
console.error('Error submitting event request:', error);
toast.error('Failed to submit event request. Please try again.');
setError('Failed to submit event request. Please try again.');
} finally {
setIsSubmitting(false);
}
};
// Validate PR Section
const validatePRSection = () => {
if (formData.flyer_type.length === 0) {
toast.error('Please select at least one flyer type');
return false;
}
if (formData.flyer_type.includes('other') && !formData.other_flyer_type) {
toast.error('Please specify the other flyer type');
return false;
}
if (formData.flyer_type.some(type =>
type === 'digital_with_social' ||
type === 'physical_with_advertising' ||
type === 'newsletter'
) && !formData.flyer_advertising_start_date) {
toast.error('Please specify when to start advertising');
return false;
}
if (formData.required_logos.includes('OTHER') && (!formData.other_logos || formData.other_logos.length === 0)) {
toast.error('Please upload your logo files');
return false;
}
if (!formData.advertising_format) {
toast.error('Please select a format');
return false;
}
if (formData.photography_needed === null || formData.photography_needed === undefined) {
toast.error('Please specify if photography is needed');
return false;
}
return true;
};
// Validate Event Details Section
const validateEventDetailsSection = () => {
if (!formData.name) {
toast.error('Please enter an event name');
return false;
}
if (!formData.event_description) {
toast.error('Please enter an event description');
return false;
}
if (!formData.start_date_time) {
toast.error('Please enter a start date and time');
return false;
}
if (!formData.end_date_time) {
toast.error('Please enter an end date and time');
return false;
}
if (!formData.location) {
toast.error('Please enter an event location');
return false;
}
if (formData.will_or_have_room_booking === null || formData.will_or_have_room_booking === undefined) {
toast.error('Please specify if you have a room booking');
return false;
}
return true;
};
// Validate TAP Form Section
const validateTAPFormSection = () => {
if (!formData.expected_attendance) {
toast.error('Please enter the expected attendance');
return false;
}
if (!formData.room_booking && formData.will_or_have_room_booking) {
toast.error('Please upload your room booking confirmation');
return false;
}
if (formData.food_drinks_being_served === null || formData.food_drinks_being_served === undefined) {
toast.error('Please specify if food/drinks will be served');
return false;
}
return true;
};
// Validate AS Funding Section
const validateASFundingSection = () => {
if (formData.needs_as_funding) {
// Check if vendor is provided
if (!formData.invoiceData.vendor) {
toast.error('Please enter the vendor/restaurant name');
return false;
}
// No longer require items in the invoice
// Check if at least one invoice file is uploaded
if (!formData.invoice && (!formData.invoice_files || formData.invoice_files.length === 0)) {
toast.error('Please upload at least one invoice file');
return false;
}
}
return true;
};
// Validate all sections before submission
const validateAllSections = () => {
// Validate Event Details
if (!validateEventDetailsSection()) {
return false;
}
// Validate TAP Form
if (!validateTAPFormSection()) {
return false;
}
// Validate PR Section if needed
if (formData.needs_graphics && !validatePRSection()) {
return false;
}
// Validate AS Funding if needed
if (formData.food_drinks_being_served && formData.needs_as_funding && !validateASFundingSection()) {
return false;
}
return true;
};
// Handle next button click with validation
const handleNextStep = (nextStep: number) => {
let isValid = true;
// Validate current section before proceeding
if (currentStep === 2 && formData.needs_graphics) {
isValid = validatePRSection();
} else if (currentStep === 3) {
isValid = validateEventDetailsSection();
} else if (currentStep === 4) {
isValid = validateTAPFormSection();
} else if (currentStep === 5 && formData.food_drinks_being_served && formData.needs_as_funding) {
isValid = validateASFundingSection();
}
if (isValid) {
// Set the current step
setCurrentStep(nextStep);
// If moving to the review step, mark the form as reviewed
// but don't submit it automatically
if (nextStep === 6) {
setFormData(prevData => ({
...prevData,
formReviewed: true
}));
}
}
};
// Handle form submission with validation
const handleSubmitWithValidation = (e: React.FormEvent) => {
e.preventDefault();
// If we're on the review step, we've already validated all sections
// Only submit if the user explicitly clicks the submit button
if (currentStep === 6 && formData.formReviewed) {
handleSubmit(e);
return;
}
// Otherwise, validate all sections before proceeding to the review step
if (validateAllSections()) {
// If we're not on the review step, go to the review step instead of submitting
handleNextStep(6);
}
};
// Render the current step
const renderCurrentSection = () => {
// Step 1: Ask if they need graphics from the design team
if (currentStep === 1) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<h2 className="text-2xl font-bold mb-4 text-primary">Event Request Form</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Welcome to the IEEE UCSD Event Request Form. This form will help you request PR materials,
provide event details, and request AS funding if needed.
</p>
</div>
<div className="bg-base-200/50 p-6 rounded-lg">
<h3 className="text-xl font-semibold mb-4">Do you need graphics from the design team?</h3>
<div className="flex flex-col sm:flex-row gap-4">
<button
className={`btn btn-lg ${formData.needs_graphics ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_graphics: true, flyers_needed: true });
setCurrentStep(2);
}}
>
Yes
</button>
<button
className={`btn btn-lg ${!formData.needs_graphics && formData.needs_graphics !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_graphics: false, flyers_needed: false });
setCurrentStep(3);
}}
>
No
</button>
</div>
</div>
</motion.div>
);
}
// Step 2: PR Section (if they need graphics)
if (currentStep === 2) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<PRSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(1)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(3)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 3: Event Details Section
if (currentStep === 3) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<EventDetailsSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(formData.needs_graphics ? 2 : 1)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(4)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 4: TAP Form Section
if (currentStep === 4) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<TAPFormSection formData={formData} onDataChange={handleSectionDataChange} />
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(3)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(5)}>
Next
</button>
</div>
</motion.div>
);
}
// Step 5: AS Funding Section
if (currentStep === 5) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
{formData.food_drinks_being_served && (
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-4">Do you need AS funding for this event?</h3>
<div className="flex flex-col sm:flex-row gap-4">
<button
className={`btn btn-lg ${formData.needs_as_funding ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_as_funding: true, as_funding_required: true });
}}
>
Yes
</button>
<button
className={`btn btn-lg ${!formData.needs_as_funding && formData.needs_as_funding !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
onClick={() => {
setFormData({ ...formData, needs_as_funding: false, as_funding_required: false });
}}
>
No
</button>
</div>
</div>
)}
{!formData.food_drinks_being_served && (
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-4">AS Funding Information</h3>
<p className="mb-4">Since you're not serving food or drinks, AS funding is not applicable for this event.</p>
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p>If you need to request AS funding for other purposes, please contact the AS office directly.</p>
</div>
</div>
</div>
)}
{formData.needs_as_funding && formData.food_drinks_being_served && (
<ASFundingSection formData={formData} onDataChange={handleSectionDataChange} />
)}
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(4)}>
Back
</button>
<button className="btn btn-primary" onClick={() => handleNextStep(6)}>
Review Form
</button>
</div>
</motion.div>
);
}
// Step 6: Review Form
if (currentStep === 6) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-6"
>
<h2 className="text-2xl font-bold mb-4 text-primary">Review Your Event Request</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Please review all information carefully before submitting. You can go back to any section to make changes if needed.
</p>
</div>
<div className="bg-base-200/50 p-6 rounded-lg">
<EventRequestFormPreview formData={formData} isModal={false} />
<div className="divider my-6">Ready to Submit?</div>
<div className="alert alert-info mb-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p>Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel.</p>
</div>
</div>
</div>
<div className="flex justify-between mt-8">
<button className="btn btn-outline" onClick={() => setCurrentStep(5)}>
Back
</button>
<button
type="button"
className="btn btn-success btn-lg"
onClick={handleSubmitWithValidation}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span className="loading loading-spinner"></span>
Submitting...
</>
) : (
'Submit Event Request'
)}
</button>
</div>
</motion.div>
);
}
return null;
};
return (
<>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="max-w-4xl mx-auto"
>
<form
onSubmit={(e) => {
// Prevent default form submission behavior
e.preventDefault();
// Only submit if the user explicitly clicks the submit button
// The actual submission is handled by handleSubmitWithValidation
}}
className="space-y-6"
>
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* Progress indicator */}
<div className="w-full mb-6">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">Step {currentStep} of 6</span>
<span className="text-sm font-medium">{Math.min(Math.round((currentStep / 6) * 100), 100)}% complete</span>
</div>
<div className="w-full bg-base-300 rounded-full h-2.5">
<div
className="bg-primary h-2.5 rounded-full transition-all duration-300"
style={{ width: `${Math.min((currentStep / 6) * 100, 100)}%` }}
></div>
</div>
</div>
{/* Current section */}
<AnimatePresence mode="wait">
{renderCurrentSection()}
</AnimatePresence>
</form>
</motion.div>
</>
);
};
export default EventRequestForm;

View file

@ -0,0 +1,497 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { InvoiceItem } from './InvoiceBuilder';
import type { EventRequest } from '../../../schemas/pocketbase';
import { FlyerTypes, LogoOptions, EventRequestStatus } from '../../../schemas/pocketbase';
// Create a standalone component that can be used to show the preview as a modal
export const EventRequestFormPreviewModal: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [formData, setFormData] = useState<EventRequestFormData | null>(null);
// Function to handle showing the modal
const showModal = (data: any) => {
console.log('showModal called with data', data);
setFormData(data);
setIsOpen(true);
};
// Add the global function to the window object directly from the component
useEffect(() => {
// Store the original function if it exists
const originalFunction = window.showEventRequestFormPreview;
// Define the global function
window.showEventRequestFormPreview = (data: any) => {
console.log('Global showEventRequestFormPreview called with data', data);
showModal(data);
};
// Listen for the custom event as a fallback
const handleShowModal = (event: CustomEvent) => {
console.log('Received showEventRequestPreviewModal event', event.detail);
if (event.detail && event.detail.formData) {
showModal(event.detail.formData);
} else {
console.error('Event detail or formData is missing', event.detail);
}
};
// Add event listener
document.addEventListener('showEventRequestPreviewModal', handleShowModal as EventListener);
console.log('Event listener for showEventRequestPreviewModal added');
// Clean up
return () => {
// Restore the original function if it existed
if (originalFunction) {
window.showEventRequestFormPreview = originalFunction;
} else {
// Otherwise delete our function
delete window.showEventRequestFormPreview;
}
document.removeEventListener('showEventRequestPreviewModal', handleShowModal as EventListener);
console.log('Event listener for showEventRequestPreviewModal removed');
};
}, []); // Empty dependency array - only run once on mount
const handleClose = () => {
console.log('Modal closed');
setIsOpen(false);
};
// Force the modal to be in the document body to avoid nesting issues
return (
<div
id="event-request-preview-modal-container"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 99999,
pointerEvents: isOpen ? 'auto' : 'none',
overflow: 'auto',
margin: 0,
padding: 0
}}
>
<EventRequestFormPreview
formData={formData || undefined}
isOpen={isOpen}
onClose={handleClose}
isModal={true}
/>
</div>
);
};
interface EventRequestFormPreviewProps {
formData?: EventRequestFormData; // Optional prop to directly pass form data
isOpen?: boolean; // Control whether the modal is open
onClose?: () => void; // Callback when modal is closed
isModal?: boolean; // Whether to render as a modal or inline component
}
const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
formData: propFormData,
isOpen = true,
onClose = () => { },
isModal = false
}) => {
const [formData, setFormData] = useState<EventRequestFormData | null>(propFormData || null);
const [loading, setLoading] = useState<boolean>(!propFormData);
// Load form data from localStorage on initial load and when updated
useEffect(() => {
// If formData is provided as a prop, use it directly
if (propFormData) {
setFormData(propFormData);
setLoading(false);
return;
}
const loadFormData = () => {
setLoading(true);
const savedData = localStorage.getItem('eventRequestFormData');
if (savedData) {
try {
const parsedData = JSON.parse(savedData);
setFormData(parsedData);
} catch (e) {
console.error('Error parsing saved form data:', e);
}
}
setLoading(false);
};
// Load initial data
loadFormData();
// Listen for form data updates
const handleFormDataUpdate = (event: CustomEvent) => {
if (event.detail && event.detail.formData) {
setFormData(event.detail.formData);
}
};
window.addEventListener('formDataUpdated', handleFormDataUpdate as EventListener);
document.addEventListener('updatePreview', loadFormData);
return () => {
window.removeEventListener('formDataUpdated', handleFormDataUpdate as EventListener);
document.removeEventListener('updatePreview', loadFormData);
};
}, [propFormData]);
// Format date and time for display
const formatDateTime = (dateTimeString: string) => {
if (!dateTimeString) return 'Not specified';
try {
const date = new Date(dateTimeString);
return date.toLocaleString();
} catch (e) {
return dateTimeString;
}
};
// Handle click on the backdrop to close the modal
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
// Render the content of the preview
const renderContent = () => {
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
if (!formData) {
return (
<div className="text-center py-12">
<h3 className="text-xl font-bold mb-4">No Form Data Available</h3>
<p className="text-gray-400">Please fill out the form to see a preview.</p>
</div>
);
}
return (
<div className="space-y-8">
<div className={`${isModal ? 'bg-base-300' : ''} p-6 rounded-lg`}>
{isModal && (
<>
<h2 className="text-2xl font-bold mb-4">Event Request Preview</h2>
<p className="text-sm text-gray-400 mb-6">
This is a preview of your event request. Please review all information before submitting.
</p>
</>
)}
{/* Event Details Section */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
Event Details
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-400">Event Name</p>
<p className="font-medium">{formData.name || 'Not specified'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Location</p>
<p className="font-medium">{formData.location || 'Not specified'}</p>
</div>
<div className="md:col-span-2 lg:col-span-3">
<p className="text-sm text-gray-400">Event Description</p>
<p className="font-medium whitespace-pre-line">{formData.event_description || 'Not specified'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Start Date & Time</p>
<p className="font-medium">{formatDateTime(formData.start_date_time)}</p>
</div>
<div>
<p className="text-sm text-gray-400">End Date & Time</p>
<p className="font-medium">{formatDateTime(formData.end_date_time)}</p>
</div>
<div>
<p className="text-sm text-gray-400">Room Booking</p>
<p className="font-medium">{formData.will_or_have_room_booking ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Expected Attendance</p>
<p className="font-medium">{formData.expected_attendance || 'Not specified'}</p>
</div>
</div>
</div>
{/* PR Materials Section */}
{formData.flyers_needed && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
PR Materials
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-400">Flyer Types</p>
<ul className="list-disc list-inside">
{formData.flyer_type.map((type, index) => (
<li key={index} className="font-medium">
{type === 'digital_with_social' && 'Digital flyer with social media advertising'}
{type === 'digital_no_social' && 'Digital flyer without social media advertising'}
{type === 'physical_with_advertising' && 'Physical flyer with advertising'}
{type === 'physical_no_advertising' && 'Physical flyer without advertising'}
{type === 'newsletter' && 'Newsletter'}
{type === 'other' && 'Other: ' + formData.other_flyer_type}
</li>
))}
</ul>
</div>
<div>
<p className="text-sm text-gray-400">Advertising Start Date</p>
<p className="font-medium">{formData.flyer_advertising_start_date || 'Not specified'}</p>
</div>
<div className="md:col-span-2 lg:col-span-3">
<p className="text-sm text-gray-400">Required Logos</p>
<div className="flex flex-wrap gap-2 mt-1">
{formData.required_logos.map((logo, index) => (
<span key={index} className="badge badge-primary">{logo}</span>
))}
{formData.required_logos.length === 0 && <p className="font-medium">None specified</p>}
</div>
</div>
<div>
<p className="text-sm text-gray-400">Advertising Format</p>
<p className="font-medium">
{formData.advertising_format === 'pdf' && 'PDF'}
{formData.advertising_format === 'jpeg' && 'JPEG'}
{formData.advertising_format === 'png' && 'PNG'}
{formData.advertising_format === 'does_not_matter' && 'Does not matter'}
{!formData.advertising_format && 'Not specified'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Photography Needed</p>
<p className="font-medium">{formData.photography_needed ? 'Yes' : 'No'}</p>
</div>
{formData.flyer_additional_requests && (
<div className="md:col-span-2 lg:col-span-3">
<p className="text-sm text-gray-400">Additional Requests</p>
<p className="font-medium whitespace-pre-line">{formData.flyer_additional_requests}</p>
</div>
)}
</div>
</div>
)}
{/* TAP Form Section */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
TAP Form Details
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-400">Expected Attendance</p>
<p className="font-medium">{formData.expected_attendance || 'Not specified'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Room Booking</p>
<p className="font-medium">
{formData.room_booking ? formData.room_booking.name : 'No file uploaded'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">AS Funding Required</p>
<p className="font-medium">{formData.as_funding_required ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm text-gray-400">Food/Drinks Being Served</p>
<p className="font-medium">{formData.food_drinks_being_served ? 'Yes' : 'No'}</p>
</div>
</div>
</div>
{/* AS Funding Section */}
{formData.as_funding_required && (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4 border-b border-base-content/20 pb-2">
AS Funding Details
</h3>
<div className="grid grid-cols-1 gap-4 mb-4">
<div>
<p className="text-sm text-gray-400">Vendor</p>
<p className="font-medium">{formData.invoiceData.vendor || 'Not specified'}</p>
</div>
</div>
{formData.invoiceData.items.length > 0 ? (
<div className="overflow-x-auto mb-4">
<table className="table w-full">
<thead>
<tr>
<th>Description</th>
<th className="text-right">Qty</th>
<th className="text-right">Unit Price</th>
<th className="text-right">Amount</th>
</tr>
</thead>
<tbody>
{formData.invoiceData.items.map((item: InvoiceItem) => (
<tr key={item.id}>
<td>{item.description}</td>
<td className="text-right">{item.quantity}</td>
<td className="text-right">${item.unitPrice.toFixed(2)}</td>
<td className="text-right">${item.amount.toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
<td className="text-right">${formData.invoiceData.subtotal.toFixed(2)}</td>
</tr>
<tr>
<td colSpan={3} className="text-right font-medium">Tax ({formData.invoiceData.taxRate}%):</td>
<td className="text-right">${formData.invoiceData.taxAmount.toFixed(2)}</td>
</tr>
<tr>
<td colSpan={3} className="text-right font-medium">Tip ({formData.invoiceData.tipPercentage}%):</td>
<td className="text-right">${formData.invoiceData.tipAmount.toFixed(2)}</td>
</tr>
<tr>
<td colSpan={3} className="text-right font-bold">Total:</td>
<td className="text-right font-bold">${formData.invoiceData.total.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
) : (
<div className="alert alert-info mb-4">
<div>No invoice items have been added yet.</div>
</div>
)}
<div className="mt-4 mb-4">
<p className="text-sm text-gray-400 mb-2">JSON Format (For Submission):</p>
<pre className="bg-base-300 p-4 rounded-lg overflow-x-auto text-xs">
{JSON.stringify({
items: formData.invoiceData.items.map(item => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice
})),
tax: formData.invoiceData.taxAmount,
tip: formData.invoiceData.tipAmount,
total: formData.invoiceData.total,
vendor: formData.invoiceData.vendor
}, null, 2)}
</pre>
<p className="text-xs text-gray-400 mt-2">
This is the structured format that will be submitted to our database.
It ensures that your invoice data is properly organized and can be
processed correctly by our system.
</p>
</div>
<div>
<p className="text-sm text-gray-400">Invoice Files</p>
{formData.invoice_files && formData.invoice_files.length > 0 ? (
<div className="mt-2 space-y-1">
{formData.invoice_files.map((file, index) => (
<p key={index} className="font-medium">{file.name}</p>
))}
</div>
) : formData.invoice ? (
<p className="font-medium">{formData.invoice.name}</p>
) : (
<p className="font-medium">No files uploaded</p>
)}
</div>
</div>
)}
</div>
</div>
);
};
// If not a modal, render the content directly
if (!isModal) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="w-full"
>
{renderContent()}
</motion.div>
);
}
// If it's a modal, render with the modal wrapper
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 flex items-center justify-center bg-black/80 backdrop-blur-md overflow-hidden"
onClick={handleBackdropClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99999,
width: '100vw',
height: '100vh',
margin: 0,
padding: 0,
overflow: 'auto'
}}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="bg-base-100 rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] overflow-y-auto m-4"
onClick={(e) => e.stopPropagation()}
style={{
position: 'relative',
zIndex: 100000,
maxWidth: '90vw',
width: '100%'
}}
>
<div className="sticky top-0 z-10 bg-base-100 px-6 py-4 border-b border-base-300 flex justify-between items-center">
<h2 className="text-xl font-bold">Event Request Preview</h2>
<button
className="btn btn-sm btn-circle btn-ghost"
onClick={onClose}
>
</button>
</div>
<div className="p-6">
{renderContent()}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
export default EventRequestFormPreview;

View file

@ -0,0 +1,423 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Invoice item interface
export interface InvoiceItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
// Invoice data interface
export interface InvoiceData {
items: InvoiceItem[];
subtotal: number;
taxRate: number;
taxAmount: number;
tipPercentage: number;
tipAmount: number;
total: number;
vendor: string;
}
interface InvoiceBuilderProps {
invoiceData: InvoiceData;
onChange: (data: InvoiceData) => void;
}
const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }) => {
// State for new item form
const [newItem, setNewItem] = useState<Omit<InvoiceItem, 'id' | 'amount'>>({
description: '',
quantity: 1,
unitPrice: 0
});
// Use a counter for generating IDs to avoid hydration issues
const [idCounter, setIdCounter] = useState(1);
// Generate a unique ID for new items without using non-deterministic functions
const generateId = () => {
const id = `item-${idCounter}`;
setIdCounter(prev => prev + 1);
return id;
};
// State for validation errors
const [errors, setErrors] = useState<{
description?: string;
quantity?: string;
unitPrice?: string;
vendor?: string;
}>({});
// Calculate totals whenever invoice data changes
useEffect(() => {
calculateTotals();
}, [invoiceData.items, invoiceData.taxRate, invoiceData.tipPercentage]);
// Calculate all totals
const calculateTotals = () => {
// Calculate subtotal
const subtotal = invoiceData.items.reduce((sum, item) => sum + item.amount, 0);
// Calculate tax amount (ensure it's based on the current subtotal)
const taxAmount = subtotal * (invoiceData.taxRate / 100);
// Calculate tip amount (ensure it's based on the current subtotal)
const tipAmount = subtotal * (invoiceData.tipPercentage / 100);
// Calculate total
const total = subtotal + taxAmount + tipAmount;
// Update invoice data
onChange({
...invoiceData,
subtotal,
taxAmount,
tipAmount,
total
});
};
// Validate new item before adding
const validateNewItem = () => {
const newErrors: {
description?: string;
quantity?: string;
unitPrice?: string;
vendor?: string;
} = {};
if (!newItem.description.trim()) {
newErrors.description = 'Description is required';
}
if (newItem.quantity <= 0) {
newErrors.quantity = 'Quantity must be greater than 0';
}
if (newItem.unitPrice < 0) {
newErrors.unitPrice = 'Unit price must be 0 or greater';
}
// Check for duplicate description
const isDuplicate = invoiceData.items.some(
item => item.description.toLowerCase() === newItem.description.toLowerCase()
);
if (isDuplicate) {
newErrors.description = 'An item with this description already exists';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Add a new item to the invoice
const handleAddItem = () => {
if (!validateNewItem()) {
return;
}
// Calculate amount
const amount = newItem.quantity * newItem.unitPrice;
// Create new item
const item: InvoiceItem = {
id: generateId(),
description: newItem.description,
quantity: newItem.quantity,
unitPrice: newItem.unitPrice,
amount
};
// Add item to invoice
onChange({
...invoiceData,
items: [...invoiceData.items, item]
});
// Show success toast
toast.success(`Added ${item.description} to invoice`);
// Reset new item form
setNewItem({
description: '',
quantity: 1,
unitPrice: 0
});
// Clear errors
setErrors({});
};
// Remove an item
const handleRemoveItem = (id: string) => {
const itemToRemove = invoiceData.items.find(item => item.id === id);
onChange({
...invoiceData,
items: invoiceData.items.filter(item => item.id !== id)
});
if (itemToRemove) {
toast.success(`Removed ${itemToRemove.description} from invoice`);
}
};
// Update tax rate
const handleTaxRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
onChange({
...invoiceData,
taxRate: isNaN(value) ? 0 : Math.max(0, value)
});
};
// Update tip percentage
const handleTipPercentageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value);
onChange({
...invoiceData,
tipPercentage: isNaN(value) ? 0 : Math.max(0, value)
});
};
// Update vendor
const handleVendorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...invoiceData,
vendor: e.target.value
});
// Clear vendor error if it exists
if (errors.vendor && e.target.value.trim()) {
setErrors({ ...errors, vendor: undefined });
}
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
return (
<motion.div variants={itemVariants} className="space-y-6">
<div className="bg-base-200/50 p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-4">Invoice Builder</h3>
{/* AS Funding Limit Notice */}
<div className="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span className="font-bold">AS Funding Limits:</span> Maximum of $10.00 per expected student attendee and $5,000 per event.
</div>
</div>
{/* Vendor information */}
<div className="form-control mb-4">
<label className="label">
<span className="label-text font-medium">Vendor/Restaurant Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className={`input input-bordered ${errors.vendor ? 'input-error' : ''}`}
value={invoiceData.vendor}
onChange={handleVendorChange}
placeholder="e.g. L&L Hawaiian Barbeque"
/>
{errors.vendor && (
<label className="label">
<span className="label-text-alt text-error">{errors.vendor}</span>
</label>
)}
</div>
{/* Item list */}
<div className="overflow-x-auto mb-4">
<table className="table w-full">
<thead>
<tr>
<th>Description</th>
<th className="text-right">Qty</th>
<th className="text-right">Unit Price</th>
<th className="text-right">Amount</th>
<th></th>
</tr>
</thead>
<tbody>
{invoiceData.items.map(item => (
<tr key={item.id} className="hover">
<td>{item.description}</td>
<td className="text-right">{item.quantity}</td>
<td className="text-right">{formatCurrency(item.unitPrice)}</td>
<td className="text-right">{formatCurrency(item.amount)}</td>
<td className="text-right">
<button
type="button"
className="btn btn-ghost btn-xs"
onClick={() => handleRemoveItem(item.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</td>
</tr>
))}
{invoiceData.items.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-4 text-gray-500">
No items added yet
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Add new item form */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Description</span>
</label>
<input
type="text"
className={`input input-bordered input-sm ${errors.description ? 'input-error' : ''}`}
value={newItem.description}
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
placeholder="e.g. Chicken Cutlet with Gravy"
/>
{errors.description && (
<label className="label">
<span className="label-text-alt text-error">{errors.description}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Quantity</span>
</label>
<input
type="number"
id="quantity"
className={`input input-bordered input-sm ${errors.quantity ? 'input-error' : ''}`}
value={newItem.quantity}
onChange={(e) => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 0 })}
min="1"
step="1"
/>
{errors.quantity && <div className="text-error text-xs mt-1">{errors.quantity}</div>}
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Unit Price ($)</span>
</label>
<input
type="number"
id="unitPrice"
className={`input input-bordered input-sm ${errors.unitPrice ? 'input-error' : ''}`}
value={newItem.unitPrice}
onChange={(e) => setNewItem({ ...newItem, unitPrice: parseFloat(e.target.value) || 0 })}
min="0"
step="0.01"
/>
{errors.unitPrice && (
<label className="label">
<span className="label-text-alt text-error">{errors.unitPrice}</span>
</label>
)}
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleAddItem}
>
Add Item
</button>
</div>
{/* Tax and tip */}
<div className="divider"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Tax Rate (%)</span>
</label>
<input
type="number"
className="input input-bordered input-sm"
value={invoiceData.taxRate}
onChange={handleTaxRateChange}
min="0"
step="0.01"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Tip Percentage (%)</span>
</label>
<input
type="number"
className="input input-bordered input-sm"
value={invoiceData.tipPercentage}
onChange={handleTipPercentageChange}
min="0"
step="0.01"
/>
</div>
</div>
{/* Totals */}
<div className="bg-base-300/30 p-4 rounded-lg">
<div className="flex justify-between mb-2">
<span>Subtotal:</span>
<span className="font-medium">{formatCurrency(invoiceData.subtotal)}</span>
</div>
<div className="flex justify-between mb-2">
<span>Tax ({invoiceData.taxRate}%):</span>
<span className="font-medium">{formatCurrency(invoiceData.taxAmount)}</span>
</div>
<div className="flex justify-between mb-2">
<span>Tip ({invoiceData.tipPercentage}%):</span>
<span className="font-medium">{formatCurrency(invoiceData.tipAmount)}</span>
</div>
<div className="flex justify-between font-bold text-lg">
<span>Total:</span>
<span>{formatCurrency(invoiceData.total)}</span>
</div>
</div>
</div>
</motion.div>
);
};
export default InvoiceBuilder;

View file

@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Flyer type options
const FLYER_TYPES = [
{ value: FlyerTypes.DIGITAL_WITH_SOCIAL, label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' },
{ value: FlyerTypes.DIGITAL_NO_SOCIAL, label: 'Digital flyer (with NO social media advertising)' },
{ value: FlyerTypes.PHYSICAL_WITH_ADVERTISING, label: 'Physical flyer (with advertising)' },
{ value: FlyerTypes.PHYSICAL_NO_ADVERTISING, label: 'Physical flyer (with NO advertising)' },
{ value: FlyerTypes.NEWSLETTER, label: 'Newsletter (IEEE, ECE, IDEA)' },
{ value: FlyerTypes.OTHER, label: 'Other' }
];
// Logo options
const LOGO_OPTIONS = [
{ value: LogoOptions.IEEE, label: 'IEEE' },
{ value: LogoOptions.AS, label: 'AS (required if funded by AS)' },
{ value: LogoOptions.HKN, label: 'HKN' },
{ value: LogoOptions.TESC, label: 'TESC' },
{ value: LogoOptions.PIB, label: 'PIB' },
{ value: LogoOptions.TNT, label: 'TNT' },
{ value: LogoOptions.SWE, label: 'SWE' },
{ value: LogoOptions.OTHER, label: 'OTHER (please upload transparent logo files)' }
];
// Format options
const FORMAT_OPTIONS = [
{ value: 'pdf', label: 'PDF' },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'png', label: 'PNG' },
{ value: 'does_not_matter', label: 'DOES NOT MATTER' }
];
interface PRSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
// Handle checkbox change for flyer types
const handleFlyerTypeChange = (type: string) => {
const updatedTypes = formData.flyer_type.includes(type)
? formData.flyer_type.filter(t => t !== type)
: [...formData.flyer_type, type];
onDataChange({ flyer_type: updatedTypes });
};
// Handle checkbox change for required logos
const handleLogoChange = (logo: string) => {
const updatedLogos = formData.required_logos.includes(logo)
? formData.required_logos.filter(l => l !== logo)
: [...formData.required_logos, logo];
onDataChange({ required_logos: updatedLogos });
};
// Handle file upload for other logos
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
setOtherLogoFiles(newFiles);
onDataChange({ other_logos: newFiles });
}
};
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 text-primary">PR Materials</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form.
</p>
</div>
{/* Type of material needed */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Type of material needed?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="space-y-2 mt-2">
{FLYER_TYPES.map((type) => (
<label key={type.value} className="flex items-start gap-2 cursor-pointer hover:bg-base-300/30 p-2 rounded-md transition-colors">
<input
type="checkbox"
className="checkbox checkbox-primary mt-1"
checked={formData.flyer_type.includes(type.value)}
onChange={() => handleFlyerTypeChange(type.value)}
/>
<span>{type.label}</span>
</label>
))}
</div>
{/* Other flyer type input */}
{formData.flyer_type.includes(FlyerTypes.OTHER) && (
<div className="mt-3 pl-7">
<input
type="text"
className="input input-bordered w-full"
placeholder="Please specify other material needed"
value={formData.other_flyer_type}
onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
required
/>
</div>
)}
</motion.div>
{/* Advertising start date */}
{formData.flyer_type.some(type =>
type === FlyerTypes.DIGITAL_WITH_SOCIAL ||
type === FlyerTypes.PHYSICAL_WITH_ADVERTISING ||
type === FlyerTypes.NEWSLETTER
) && (
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">When do you need us to start advertising?</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.flyer_advertising_start_date}
onChange={(e) => onDataChange({ flyer_advertising_start_date: e.target.value })}
required
/>
</motion.div>
)}
{/* Logos Required */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Logos Required</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{LOGO_OPTIONS.map((logo) => (
<label key={logo.value} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
className="checkbox checkbox-primary mt-1"
checked={formData.required_logos.includes(logo.value)}
onChange={() => handleLogoChange(logo.value)}
/>
<span>{logo.label}</span>
</label>
))}
</div>
</motion.div>
{/* Logo file upload */}
{formData.required_logos.includes(LogoOptions.OTHER) && (
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Please share your logo files here</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="file"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleLogoFileChange}
accept="image/*"
multiple
required
/>
{otherLogoFiles.length > 0 && (
<div className="mt-2">
<p className="text-sm font-medium mb-1">Selected files:</p>
<ul className="list-disc list-inside text-sm">
{otherLogoFiles.map((file, index) => (
<li key={index}>{file.name}</li>
))}
</ul>
</div>
)}
</motion.div>
)}
{/* Format */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">What format do you need it to be in?</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
value={formData.advertising_format}
onChange={(e) => onDataChange({ advertising_format: e.target.value })}
required
>
<option value="">Select format</option>
{FORMAT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</motion.div>
{/* Additional specifications */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Any other specifications and requests?</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
value={formData.flyer_additional_requests}
onChange={(e) => onDataChange({ flyer_additional_requests: e.target.value })}
placeholder="Color scheme, overall design, examples to consider, etc."
rows={4}
/>
</motion.div>
{/* Photography Needed */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Photography Needed?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.photography_needed === true}
onChange={() => onDataChange({ photography_needed: true })}
required
/>
<span>Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.photography_needed === false}
onChange={() => onDataChange({ photography_needed: false })}
required
/>
<span>No</span>
</label>
</div>
</motion.div>
</div>
);
};
export default PRSection;

View file

@ -0,0 +1,151 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
// Animation variants
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
interface TAPFormSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
// Handle room booking file upload
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
setRoomBookingFile(file);
onDataChange({ room_booking: file });
}
};
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 text-primary">TAP Form Information</h2>
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
<p className="text-sm">
Please ensure you have ALL sections completed. If something is not available, let the coordinators know and be advised on how to proceed.
</p>
</div>
{/* Expected attendance */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Expected attendance? Include a number NOT a range please.</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="relative mt-2">
<input
type="number"
className="input input-bordered focus:input-primary transition-all duration-300 w-full"
value={formData.expected_attendance || ''}
onChange={(e) => onDataChange({ expected_attendance: parseInt(e.target.value) || 0 })}
min="0"
placeholder="Enter expected attendance"
required
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400">
people
</div>
</div>
<div className="text-xs text-gray-400 mt-2">
<p>PROGRAMMING FUNDS EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.</p>
<p>ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.</p>
<p>EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT</p>
</div>
</motion.div>
{/* Room booking confirmation */}
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
<label className="label">
<span className="label-text font-medium text-lg">Room booking confirmation</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="mt-2">
<input
type="file"
className="file-input file-input-bordered file-input-primary w-full"
onChange={handleRoomBookingFileChange}
accept=".pdf,.png,.jpg,.jpeg"
/>
{roomBookingFile && (
<p className="text-sm mt-2">
Selected file: {roomBookingFile.name}
</p>
)}
<p className="text-xs text-gray-500 mt-2">
Please upload a screenshot of your room booking confirmation. Accepted formats: PDF, PNG, JPG.
</p>
</div>
</motion.div>
{/* Food/Drinks */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Will you be serving food/drinks at your event?</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.food_drinks_being_served === true}
onChange={() => onDataChange({ food_drinks_being_served: true })}
required
/>
<span>Yes</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="radio radio-primary"
checked={formData.food_drinks_being_served === false}
onChange={() => onDataChange({ food_drinks_being_served: false })}
required
/>
<span>No</span>
</label>
</div>
</motion.div>
{/* AS Funding Notice - only show if food/drinks are being served */}
{formData.food_drinks_being_served && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="alert alert-info"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 className="font-bold">Food and Drinks Information</h3>
<div className="text-xs">
If you're serving food or drinks, you'll be asked about AS funding in the next step. Please be prepared with vendor information and invoice details.
</div>
</div>
</motion.div>
)}
</div>
);
};
export default TAPFormSection;

View file

@ -0,0 +1,764 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase';
// Declare the global window interface to include our custom function
declare global {
interface Window {
showEventRequestFormPreview?: (formData: any) => void;
}
}
// Extended EventRequest interface with additional properties needed for this component
export interface EventRequest extends SchemaEventRequest {
invoice_data?: any;
}
interface UserEventRequestsProps {
eventRequests: EventRequest[];
}
const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: initialEventRequests }) => {
const [eventRequests, setEventRequests] = useState<EventRequest[]>(initialEventRequests);
const [selectedRequest, setSelectedRequest] = useState<EventRequest | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const dataSync = DataSyncService.getInstance();
// Refresh event requests
const refreshEventRequests = async () => {
setIsRefreshing(true);
try {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
return;
}
const userId = auth.getUserId();
if (!userId) {
return;
}
// Use DataSyncService to get data from IndexedDB with forced sync
const updatedRequests = await dataSync.getData<EventRequest>(
Collections.EVENT_REQUESTS,
true, // Force sync
`requested_user="${userId}"`,
'-created'
);
setEventRequests(updatedRequests);
} catch (err) {
console.error('Failed to refresh event requests:', err);
} finally {
setIsRefreshing(false);
}
};
// Auto refresh on component mount
useEffect(() => {
refreshEventRequests();
}, []);
// Listen for tab visibility changes and refresh data when tab becomes visible
useEffect(() => {
const handleTabVisible = () => {
console.log("Tab became visible, refreshing event requests...");
refreshEventRequests();
};
// Add event listener for custom dashboardTabVisible event
document.addEventListener("dashboardTabVisible", handleTabVisible);
// Clean up event listener on component unmount
return () => {
document.removeEventListener("dashboardTabVisible", handleTabVisible);
};
}, []);
// Format date for display
const formatDate = (dateString: string) => {
if (!dateString) return 'Not specified';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
};
// Get status badge class based on status
const getStatusBadge = (status?: string) => {
if (!status) return 'badge-warning';
switch (status.toLowerCase()) {
case 'approved':
case 'completed':
return 'badge-success text-white';
case 'rejected':
case 'declined':
return 'badge-error text-white';
case 'pending':
return 'badge-warning text-black';
case 'submitted':
return 'badge-info text-white';
default:
return 'badge-warning text-black';
}
};
// Get card border class based on status
const getCardBorderClass = (status?: string) => {
if (!status) return 'border-l-warning';
switch (status.toLowerCase()) {
case 'approved':
case 'completed':
return 'border-l-success';
case 'rejected':
case 'declined':
return 'border-l-error';
case 'pending':
return 'border-l-warning';
case 'submitted':
return 'border-l-info';
default:
return 'border-l-warning';
}
};
// Open modal with event request details
const openDetailModal = (request: EventRequest) => {
setSelectedRequest(request);
setIsModalOpen(true);
};
// Close modal
const closeModal = () => {
setIsModalOpen(false);
setSelectedRequest(null);
};
if (eventRequests.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
>
<div className="flex flex-col items-center justify-center py-6">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
<p className="text-base-content/60 mb-6 max-w-md">You haven't submitted any event requests yet. Use the form above to submit a new event request.</p>
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
</div>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-6"
>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
<h3 className="text-lg font-semibold">Your Submissions</h3>
<div className="flex items-center gap-3">
<div className="join">
<button
className={`join-item btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
onClick={() => setViewMode('table')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
<button
className={`join-item btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
</div>
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
</div>
</div>
{viewMode === 'table' ? (
<div className="overflow-x-auto rounded-xl shadow-sm">
<table className="table table-zebra w-full">
<thead className="bg-base-300/50">
<tr>
<th>Event Name</th>
<th>Date</th>
<th>Location</th>
<th>PR Materials</th>
<th>AS Funding</th>
<th>Submitted</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{eventRequests.map((request) => (
<tr key={request.id} className={`hover border-l-4 ${getCardBorderClass(request.status)}`}>
<td className="font-medium">{request.name}</td>
<td>{formatDate(request.start_date_time)}</td>
<td>{request.location}</td>
<td>
{request.flyers_needed ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td>
{request.as_funding_required ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td>{formatDate(request.created)}</td>
<td>
<span className={`badge ${getStatusBadge(request.status)} badge-sm`}>
{request.status || 'Submitted'}
</span>
</td>
<td>
<button
className="btn btn-ghost btn-sm rounded-full"
onClick={() => openDetailModal(request)}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventRequests.map((request) => (
<motion.div
key={request.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`card bg-base-200 shadow-sm hover:shadow-md transition-shadow border-l-4 ${getCardBorderClass(request.status)}`}
>
<div className="card-body p-5">
<div className="flex justify-between items-start">
<h3 className="card-title text-base">{request.name}</h3>
<span className={`badge ${getStatusBadge(request.status)}`}>
{request.status || 'Pending'}
</span>
</div>
<div className="space-y-2 mt-2 text-sm">
<div className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{formatDate(request.start_date_time)}</span>
</div>
<div className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{request.location}</span>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{request.flyers_needed && (
<span className="badge badge-outline badge-sm">PR Materials</span>
)}
{request.as_funding_required && (
<span className="badge badge-outline badge-sm">AS Funding</span>
)}
{request.photography_needed && (
<span className="badge badge-outline badge-sm">Photography</span>
)}
</div>
<div className="card-actions justify-end mt-4">
<button
className="btn btn-primary btn-sm"
onClick={() => openDetailModal(request)}
>
View Details
</button>
</div>
</div>
</motion.div>
))}
</div>
)}
<div className="bg-base-300/30 p-5 rounded-xl text-sm shadow-sm">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
About Your Submissions
</h3>
<ul className="space-y-2 ml-2">
<li className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Event requests are typically reviewed within 1-2 business days.
</li>
<li className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
You'll receive email notifications when your request status changes.
</li>
<li className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
For urgent inquiries, please contact the PR team or coordinators in the #-events Slack channel.
</li>
</ul>
</div>
{/* Event Request Detail Modal */}
<AnimatePresence>
{isModalOpen && selectedRequest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm"
onClick={closeModal}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="bg-base-100 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 bg-base-100 px-6 py-4 border-b border-base-300 flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">{selectedRequest.name}</h2>
<span className={`badge ${getStatusBadge(selectedRequest.status)}`}>
{selectedRequest.status || 'Pending'}
</span>
</div>
<div className="flex items-center gap-2">
<button
className="btn btn-sm btn-primary"
onClick={(e) => {
e.stopPropagation();
console.log('Full Preview button clicked', selectedRequest);
try {
// Direct call to the global function
if (typeof window.showEventRequestFormPreview === 'function') {
window.showEventRequestFormPreview(selectedRequest);
} else {
console.error('showEventRequestFormPreview is not a function', window.showEventRequestFormPreview);
// Fallback to event dispatch if function is not available
const event = new CustomEvent("showEventRequestPreviewModal", {
detail: { formData: selectedRequest }
});
document.dispatchEvent(event);
console.log('Fallback: showEventRequestPreviewModal event dispatched');
}
} catch (error) {
console.error('Error showing full preview:', error);
}
}}
>
Full Preview
</button>
<button
className="btn btn-sm btn-circle btn-ghost"
onClick={closeModal}
>
</button>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Event Details
</h3>
<div className="space-y-4 bg-base-200/50 p-4 rounded-lg">
<div>
<p className="text-sm text-base-content/60">Event Name</p>
<p className="font-medium">{selectedRequest.name}</p>
</div>
<div>
<p className="text-sm text-base-content/60">Location</p>
<p className="font-medium">{selectedRequest.location}</p>
</div>
<div>
<p className="text-sm text-base-content/60">Start Date & Time</p>
<p className="font-medium">{formatDate(selectedRequest.start_date_time)}</p>
</div>
<div>
<p className="text-sm text-base-content/60">End Date & Time</p>
<p className="font-medium">{formatDate(selectedRequest.end_date_time)}</p>
</div>
<div>
<p className="text-sm text-base-content/60">Room Booking</p>
<p className="font-medium">{selectedRequest.will_or_have_room_booking ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm text-base-content/60">Expected Attendance</p>
<p className="font-medium">{selectedRequest.expected_attendance || 'Not specified'}</p>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Event Description
</h3>
<div className="bg-base-200/50 p-4 rounded-lg h-full">
<p className="whitespace-pre-line">{selectedRequest.event_description}</p>
</div>
</div>
</div>
{selectedRequest.flyers_needed && (
<div className="mb-8">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
PR Materials
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-base-200/50 p-4 rounded-lg">
<div>
<p className="text-sm text-base-content/60">Flyer Types</p>
<p className="font-medium">
{selectedRequest.flyer_type?.join(', ') || 'Not specified'}
{selectedRequest.other_flyer_type && ` (${selectedRequest.other_flyer_type})`}
</p>
</div>
<div>
<p className="text-sm text-base-content/60">Advertising Start Date</p>
<p className="font-medium">
{selectedRequest.flyer_advertising_start_date
? formatDate(selectedRequest.flyer_advertising_start_date)
: 'Not specified'}
</p>
</div>
<div>
<p className="text-sm text-base-content/60">Required Logos</p>
<p className="font-medium">
{selectedRequest.required_logos?.join(', ') || 'None'}
</p>
</div>
<div>
<p className="text-sm text-base-content/60">Advertising Format</p>
<p className="font-medium">{selectedRequest.advertising_format || 'Not specified'}</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-base-content/60">Additional Requests</p>
<p className="font-medium whitespace-pre-line">
{selectedRequest.flyer_additional_requests || 'None'}
</p>
</div>
</div>
</div>
)}
{selectedRequest.as_funding_required && (
<div className="mb-8">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
AS Funding Details
</h3>
<div className="space-y-4 bg-base-200/50 p-4 rounded-lg">
<div>
<p className="text-sm text-base-content/60">Food/Drinks Being Served</p>
<p className="font-medium">
{selectedRequest.food_drinks_being_served ? 'Yes' : 'No'}
</p>
</div>
{selectedRequest.invoice_data && (
<div>
<p className="text-sm text-base-content/60">Vendor</p>
<p className="font-medium">
{selectedRequest.invoice_data.vendor || 'Not specified'}
</p>
</div>
)}
<div>
<p className="text-sm text-base-content/60">Itemized Invoice</p>
{(() => {
try {
let invoiceData: any = null;
// Parse the invoice data if it's a string, or use it directly if it's an object
if (typeof selectedRequest.itemized_invoice === 'string') {
try {
invoiceData = JSON.parse(selectedRequest.itemized_invoice);
} catch (e) {
console.error('Failed to parse invoice JSON:', e);
return (
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
{selectedRequest.itemized_invoice || 'Not provided'}
</pre>
);
}
} else if (typeof selectedRequest.itemized_invoice === 'object') {
invoiceData = selectedRequest.itemized_invoice;
}
// If we have valid invoice data with items
if (invoiceData && Array.isArray(invoiceData.items) && invoiceData.items.length > 0) {
// Calculate total from items if not provided or if NaN
let calculatedTotal = 0;
// Try to use the provided total first
if (invoiceData.total !== undefined) {
const parsedTotal = typeof invoiceData.total === 'string'
? parseFloat(invoiceData.total)
: invoiceData.total;
if (!isNaN(parsedTotal)) {
calculatedTotal = parsedTotal;
}
}
// If total is NaN or not provided, calculate from items
if (calculatedTotal === 0 || isNaN(calculatedTotal)) {
calculatedTotal = invoiceData.items.reduce((sum: number, item: any) => {
const quantity = typeof item.quantity === 'string'
? parseFloat(item.quantity)
: (item.quantity || 1);
const unitPrice = typeof item.unit_price === 'string'
? parseFloat(item.unit_price)
: (item.unit_price || 0);
const itemTotal = !isNaN(quantity) && !isNaN(unitPrice)
? quantity * unitPrice
: 0;
return sum + itemTotal;
}, 0);
// Add tax and tip if available
if (invoiceData.tax && !isNaN(parseFloat(invoiceData.tax))) {
calculatedTotal += parseFloat(invoiceData.tax);
}
if (invoiceData.tip && !isNaN(parseFloat(invoiceData.tip))) {
calculatedTotal += parseFloat(invoiceData.tip);
}
}
return (
<div className="bg-base-300 p-3 rounded-lg overflow-x-auto mt-2">
<table className="table w-full">
<thead>
<tr>
<th>Item</th>
<th className="text-right">Qty</th>
<th className="text-right">Price</th>
<th className="text-right">Total</th>
</tr>
</thead>
<tbody>
{invoiceData.items.map((item: any, index: number) => {
const quantity = typeof item.quantity === 'string'
? parseFloat(item.quantity)
: (item.quantity || 1);
const unitPrice = typeof item.unit_price === 'string'
? parseFloat(item.unit_price)
: (item.unit_price || 0);
const itemTotal = !isNaN(quantity) && !isNaN(unitPrice)
? quantity * unitPrice
: 0;
return (
<tr key={index}>
<td>{item.item || 'Unnamed item'}</td>
<td className="text-right">{!isNaN(quantity) ? quantity : 1}</td>
<td className="text-right">${!isNaN(unitPrice) ? unitPrice.toFixed(2) : '0.00'}</td>
<td className="text-right">${!isNaN(itemTotal) ? itemTotal.toFixed(2) : '0.00'}</td>
</tr>
);
})}
</tbody>
<tfoot>
{invoiceData.tax !== undefined && (
<tr>
<td colSpan={3} className="text-right font-medium">Tax:</td>
<td className="text-right">
${typeof invoiceData.tax === 'string'
? (parseFloat(invoiceData.tax) || 0).toFixed(2)
: (invoiceData.tax || 0).toFixed(2)}
</td>
</tr>
)}
{invoiceData.tip !== undefined && (
<tr>
<td colSpan={3} className="text-right font-medium">Tip:</td>
<td className="text-right">
${typeof invoiceData.tip === 'string'
? (parseFloat(invoiceData.tip) || 0).toFixed(2)
: (invoiceData.tip || 0).toFixed(2)}
</td>
</tr>
)}
<tr>
<td colSpan={3} className="text-right font-bold">Total:</td>
<td className="text-right font-bold">
${!isNaN(calculatedTotal) ? calculatedTotal.toFixed(2) : '0.00'}
</td>
</tr>
</tfoot>
</table>
{invoiceData.vendor && (
<div className="mt-3 text-sm">
<span className="font-medium">Vendor:</span> {invoiceData.vendor}
</div>
)}
</div>
);
} else if (invoiceData && typeof invoiceData.total !== 'undefined') {
// If we have a total but no items, show a simplified view
const total = typeof invoiceData.total === 'string'
? parseFloat(invoiceData.total)
: invoiceData.total;
return (
<div className="bg-base-300 p-3 rounded-lg mt-2">
<div className="flex justify-between items-center">
<span className="font-medium">Total Amount:</span>
<span className="font-bold">${!isNaN(total) ? total.toFixed(2) : '0.00'}</span>
</div>
{invoiceData.vendor && (
<div className="mt-2">
<span className="font-medium">Vendor:</span> {invoiceData.vendor}
</div>
)}
</div>
);
} else {
// Fallback to display the JSON in a readable format
return (
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
{typeof selectedRequest.itemized_invoice === 'object'
? JSON.stringify(selectedRequest.itemized_invoice, null, 2)
: (selectedRequest.itemized_invoice || 'Not provided')}
</pre>
);
}
} catch (error) {
console.error('Error rendering invoice:', error);
return (
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
Error displaying invoice. Please check the console for details.
</pre>
);
}
})()}
</div>
</div>
</div>
)}
<div className="mt-8 pt-4 border-t border-base-300">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<p className="text-sm text-base-content/60">Submission Date</p>
<p className="font-medium">{formatDate(selectedRequest.created)}</p>
</div>
<div className="flex items-center gap-2">
<p className="text-sm text-base-content/60">Status:</p>
<span className={`badge ${getStatusBadge(selectedRequest.status)} badge-lg`}>
{selectedRequest.status || 'Pending'}
</span>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
export default UserEventRequests;

View file

@ -0,0 +1,208 @@
---
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import { toast } from "react-hot-toast";
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
import type { EventRequest } from "../../schemas/pocketbase";
import { Collections } from "../../schemas/pocketbase/schema";
import { Icon } from "astro-icon/components";
// Get instances
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends EventRequest {
requested_user_expand?: {
name: string;
email: string;
};
expand?: {
requested_user?: {
id: string;
name: string;
email: string;
[key: string]: any;
};
[key: string]: any;
};
[key: string]: any; // For other optional properties
}
// Initialize variables for all event requests
let allEventRequests: ExtendedEventRequest[] = [];
let error = null;
try {
// Don't check authentication here - let the client component handle it
// The server-side check is causing issues when the token is valid client-side but not server-side
console.log("Fetching event requests in Astro component...");
// Expand the requested_user field to get user details
allEventRequests = await get
.getAll<ExtendedEventRequest>(Collections.EVENT_REQUESTS, "", "-created", {
expand: ["requested_user"],
})
.catch((err) => {
console.error("Error in get.getAll:", err);
// Return empty array instead of throwing
return [];
});
console.log(
`Fetched ${allEventRequests.length} event requests in Astro component`,
);
// Process the event requests to add the requested_user_expand property
allEventRequests = allEventRequests.map((request) => {
const requestWithExpand = { ...request };
// Add the requested_user_expand property if the expand data is available
if (
request.expand &&
request.expand.requested_user &&
request.expand.requested_user.name &&
request.expand.requested_user.email
) {
requestWithExpand.requested_user_expand = {
name: request.expand.requested_user.name,
email: request.expand.requested_user.email,
};
}
return requestWithExpand;
});
} catch (err) {
console.error("Error fetching event requests:", err);
error = err;
}
---
<div class="w-full max-w-7xl mx-auto py-8 px-4">
<style>
.event-table-container {
min-height: 600px;
height: auto !important;
max-height: none !important;
}
.event-table-container table {
height: auto !important;
}
.event-table-container .overflow-x-auto {
max-height: none !important;
}
</style>
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Event Request Management</h1>
<p class="text-gray-300 mb-4">
Review and manage event requests submitted by officers. Update status and
coordinate with the team.
</p>
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
<p class="font-medium mb-2">As an executive officer, you can:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>View all submitted event requests</li>
<li>Update the status of requests (Pending, Completed, Declined)</li>
<li>Filter and sort requests by various criteria</li>
</ul>
</div>
</div>
{
error && (
<div class="alert alert-error mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 stroke-current shrink-0"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
)
}
{
!error && (
<div class="bg-base-200 rounded-lg shadow-xl min-h-[600px] event-table-container">
<div class="p-4 md:p-6 h-auto">
<EventRequestManagementTable
client:load
eventRequests={allEventRequests}
/>
</div>
</div>
)
}
</div>
<script>
// Import the DataSyncService for client-side use
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { Collections } from "../../schemas/pocketbase/schema";
// Remove the visibilitychange event listener that causes full page refresh
// Instead, we'll use a more efficient approach to refresh data only when needed
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
// Instead of reloading the entire page, dispatch a custom event
// that components can listen for to refresh their data
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
});
// Handle authentication errors
document.addEventListener("DOMContentLoaded", async () => {
// Initialize DataSyncService for client-side
const dataSync = DataSyncService.getInstance();
// Prefetch data into IndexedDB
try {
await dataSync.syncCollection(
Collections.EVENT_REQUESTS,
"",
"-created",
{ expand: "requested_user" },
);
console.log("Initial data sync complete");
} catch (err) {
console.error("Error during initial data sync:", err);
}
// Check for error message in the UI
const errorElement = document.querySelector(".alert-error span");
if (
errorElement &&
errorElement.textContent?.includes("Authentication error")
) {
console.log(
"Authentication error detected in UI, redirecting to login...",
);
// Redirect to login page after a short delay
setTimeout(() => {
window.location.href = "/login";
}, 3000);
return;
}
// Also check if we have any event requests
const tableContainer = document.querySelector(".event-table-container");
if (tableContainer) {
console.log(
"Event table container found, component should load normally",
);
} else {
console.log(
"Event table container not found, might be an issue with rendering",
);
}
});
</script>

View file

@ -0,0 +1,466 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends SchemaEventRequest {
requested_user_expand?: {
name: string;
email: string;
};
invoice_data?: string | any;
}
interface EventRequestDetailsProps {
request: ExtendedEventRequest;
onClose: () => void;
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
}
// Separate component for AS Funding tab to isolate any issues
const ASFundingTab: React.FC<{ request: ExtendedEventRequest }> = ({ request }) => {
if (!request.as_funding_required) {
return (
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">AS Funding Required</h4>
<p>No</p>
</div>
);
}
// Process invoice data for display
let invoiceData = request.invoice_data;
// If invoice_data is not available, try to parse itemized_invoice
if (!invoiceData && request.itemized_invoice) {
try {
if (typeof request.itemized_invoice === 'string') {
invoiceData = JSON.parse(request.itemized_invoice);
} else if (typeof request.itemized_invoice === 'object') {
invoiceData = request.itemized_invoice;
}
} catch (e) {
console.error('Failed to parse itemized_invoice:', e);
}
}
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">AS Funding Required</h4>
<p>Yes</p>
</div>
{request.food_drinks_being_served && (
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Food/Drinks Being Served</h4>
<p>Yes</p>
</div>
)}
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Invoice Data</h4>
<InvoiceTable invoiceData={invoiceData} />
</div>
</div>
);
};
// Separate component for invoice table
const InvoiceTable: React.FC<{ invoiceData: any }> = ({ invoiceData }) => {
try {
// Parse invoice data if it's a string
let parsedInvoice = null;
if (typeof invoiceData === 'string') {
try {
parsedInvoice = JSON.parse(invoiceData);
} catch (e) {
console.error('Failed to parse invoice data string:', e);
return (
<div className="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Invalid invoice data format.</span>
</div>
);
}
} else if (typeof invoiceData === 'object' && invoiceData !== null) {
parsedInvoice = invoiceData;
}
// Check if we have valid invoice data
if (!parsedInvoice || typeof parsedInvoice !== 'object') {
return (
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No structured invoice data available.</span>
</div>
);
}
// Extract items array
let items = [];
if (parsedInvoice.items && Array.isArray(parsedInvoice.items)) {
items = parsedInvoice.items;
} else if (Array.isArray(parsedInvoice)) {
items = parsedInvoice;
} else if (parsedInvoice.items && typeof parsedInvoice.items === 'object') {
items = [parsedInvoice.items]; // Wrap single item in array
} else {
// Try to find any array in the object
for (const key in parsedInvoice) {
if (Array.isArray(parsedInvoice[key])) {
items = parsedInvoice[key];
break;
}
}
}
// If we still don't have items, check if the object itself looks like an item
if (items.length === 0 && parsedInvoice.item || parsedInvoice.description || parsedInvoice.name) {
items = [parsedInvoice];
}
// If we still don't have items, show a message
if (items.length === 0) {
return (
<div className="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No invoice items found in the data.</span>
</div>
);
}
// Calculate subtotal from items
const subtotal = items.reduce((sum: number, item: any) => {
const quantity = parseFloat(item?.quantity || 1);
const price = parseFloat(item?.unit_price || item?.price || 0);
return sum + (quantity * price);
}, 0);
// Get tax, tip and total
const tax = parseFloat(parsedInvoice.tax || parsedInvoice.taxAmount || 0);
const tip = parseFloat(parsedInvoice.tip || parsedInvoice.tipAmount || 0);
const total = parseFloat(parsedInvoice.total || 0) || (subtotal + tax + tip);
// Render the invoice table
return (
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{items.map((item: any, index: number) => {
// Ensure we're not trying to render an object directly
const itemName = typeof item?.item === 'object'
? JSON.stringify(item.item)
: (item?.item || item?.description || item?.name || 'N/A');
const quantity = parseFloat(item?.quantity || 1);
const unitPrice = parseFloat(item?.unit_price || item?.price || 0);
const itemTotal = quantity * unitPrice;
return (
<tr key={index}>
<td>{itemName}</td>
<td>{quantity}</td>
<td>${unitPrice.toFixed(2)}</td>
<td>${itemTotal.toFixed(2)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={3} className="text-right font-medium">Subtotal:</td>
<td>${subtotal.toFixed(2)}</td>
</tr>
{tax > 0 && (
<tr>
<td colSpan={3} className="text-right font-medium">Tax:</td>
<td>${tax.toFixed(2)}</td>
</tr>
)}
{tip > 0 && (
<tr>
<td colSpan={3} className="text-right font-medium">Tip:</td>
<td>${tip.toFixed(2)}</td>
</tr>
)}
<tr>
<td colSpan={3} className="text-right font-bold">Total:</td>
<td className="font-bold">${total.toFixed(2)}</td>
</tr>
</tfoot>
</table>
{parsedInvoice.vendor && (
<div className="mt-3">
<span className="font-medium">Vendor:</span> {parsedInvoice.vendor}
</div>
)}
</div>
);
} catch (error) {
console.error('Error rendering invoice table:', error);
return (
<div className="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>An unexpected error occurred while processing the invoice.</span>
</div>
);
}
};
const EventRequestDetails = ({
request,
onClose,
onStatusChange
}: EventRequestDetailsProps): React.ReactNode => {
const [activeTab, setActiveTab] = useState<'details' | 'pr' | 'funding'>('details');
const [status, setStatus] = useState<"submitted" | "pending" | "completed" | "declined">(request.status);
const [isStatusChanging, setIsStatusChanging] = useState(false);
// Format date for display
const formatDate = (dateString: string) => {
if (!dateString) return 'Not specified';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
};
// Get status badge class based on status
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
if (!status) return 'badge-warning';
switch (status) {
case 'completed':
return 'badge-success';
case 'declined':
return 'badge-error';
case 'pending':
return 'badge-warning';
case 'submitted':
return 'badge-info';
default:
return 'badge-warning';
}
};
// Handle status change
const handleStatusChange = async (newStatus: "submitted" | "pending" | "completed" | "declined") => {
setIsStatusChanging(true);
await onStatusChange(request.id, newStatus);
setStatus(newStatus);
setIsStatusChanging(false);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="bg-base-200 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
>
{/* Header */}
<div className="bg-base-300 p-4 flex justify-between items-center">
<h3 className="text-xl font-bold">{request.name}</h3>
<button
className="btn btn-sm btn-circle"
onClick={onClose}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Status and controls */}
<div className="bg-base-300/50 p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">Status:</span>
<span className={`badge ${getStatusBadge(status)}`}>
{status || 'Pending'}
</span>
</div>
<div className="text-sm text-gray-400">
Requested by: <span className="text-white">{request.requested_user_expand?.name || request.requested_user || 'Unknown'}</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-sm">
Update Status
</label>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
<li><a onClick={() => handleStatusChange('pending')}>Pending</a></li>
<li><a onClick={() => handleStatusChange('completed')}>Completed</a></li>
<li><a onClick={() => handleStatusChange('declined')}>Declined</a></li>
</ul>
</div>
</div>
</div>
{/* Tabs */}
<div className="tabs tabs-boxed bg-base-300/30 px-4 pt-4">
<a
className={`tab ${activeTab === 'details' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('details')}
>
Event Details
</a>
<a
className={`tab ${activeTab === 'pr' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('pr')}
>
PR Materials
</a>
<a
className={`tab ${activeTab === 'funding' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('funding')}
>
AS Funding
</a>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-grow">
{/* Event Details Tab */}
{activeTab === 'details' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Event Name</h4>
<p className="text-lg">{request.name}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Location</h4>
<p>{request.location || 'Not specified'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Start Date & Time</h4>
<p>{formatDate(request.start_date_time)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">End Date & Time</h4>
<p>{formatDate(request.end_date_time)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Expected Attendance</h4>
<p>{request.expected_attendance || 'Not specified'}</p>
</div>
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Event Description</h4>
<p className="whitespace-pre-line">{request.event_description || 'No description provided'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Room Booking</h4>
<p>{request.will_or_have_room_booking ? 'Yes' : 'No'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Food/Drinks Served</h4>
<p>{request.food_drinks_being_served ? 'Yes' : 'No'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Submission Date</h4>
<p>{formatDate(request.created)}</p>
</div>
</div>
</div>
</div>
)}
{/* PR Materials Tab */}
{activeTab === 'pr' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyers Needed</h4>
<p>{request.flyers_needed ? 'Yes' : 'No'}</p>
</div>
{request.flyers_needed && (
<>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Flyer Types</h4>
<ul className="list-disc list-inside">
{request.flyer_type?.map((type, index) => (
<li key={index}>{type}</li>
))}
{request.other_flyer_type && <li>{request.other_flyer_type}</li>}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Start Date</h4>
<p>{formatDate(request.flyer_advertising_start_date || '')}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Advertising Format</h4>
<p>{request.advertising_format || 'Not specified'}</p>
</div>
</>
)}
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Photography Needed</h4>
<p>{request.photography_needed ? 'Yes' : 'No'}</p>
</div>
{request.flyers_needed && (
<>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Required Logos</h4>
<ul className="list-disc list-inside">
{request.required_logos?.map((logo, index) => (
<li key={index}>{logo}</li>
))}
{(!request.required_logos || request.required_logos.length === 0) &&
<li>No specific logos required</li>
}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-gray-400 mb-1">Additional Requests</h4>
<p className="whitespace-pre-line">{request.flyer_additional_requests || 'None'}</p>
</div>
</>
)}
</div>
</div>
</div>
)}
{/* AS Funding Tab */}
{activeTab === 'funding' && (
<ASFundingTab request={request} />
)}
</div>
</motion.div>
</div>
);
};
export default EventRequestDetails;

View file

@ -0,0 +1,662 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import toast from 'react-hot-toast';
import EventRequestDetails from './EventRequestDetails';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends SchemaEventRequest {
requested_user_expand?: {
name: string;
email: string;
};
expand?: {
requested_user?: {
id: string;
name: string;
email: string;
[key: string]: any;
};
[key: string]: any;
};
invoice_data?: any;
status: "submitted" | "pending" | "completed" | "declined";
}
interface EventRequestManagementTableProps {
eventRequests: ExtendedEventRequest[];
}
const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: EventRequestManagementTableProps) => {
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [selectedRequest, setSelectedRequest] = useState<ExtendedEventRequest | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState<string>('');
const [sortField, setSortField] = useState<string>('created');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const dataSync = DataSyncService.getInstance();
// Add state for update modal
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
// Refresh event requests
const refreshEventRequests = async () => {
setIsRefreshing(true);
try {
const auth = Authentication.getInstance();
// Don't check authentication here - try to fetch anyway
// The token might be valid for the API even if isAuthenticated() returns false
console.log("Fetching event requests...");
// Use DataSyncService to get data from IndexedDB with forced sync
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
Collections.EVENT_REQUESTS,
true, // Force sync
'', // No filter
'-created',
'requested_user'
);
console.log(`Fetched ${updatedRequests.length} event requests`);
setEventRequests(updatedRequests);
applyFilters(updatedRequests);
} catch (error) {
console.error('Error refreshing event requests:', error);
toast.error('Failed to refresh event requests');
} finally {
setIsRefreshing(false);
}
};
// Apply filters and sorting
const applyFilters = (requests = eventRequests) => {
let filtered = [...requests];
// Apply status filter
if (statusFilter !== 'all') {
filtered = filtered.filter(request =>
request.status?.toLowerCase() === statusFilter.toLowerCase()
);
}
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(request =>
request.name.toLowerCase().includes(term) ||
request.location.toLowerCase().includes(term) ||
request.event_description.toLowerCase().includes(term) ||
request.expand?.requested_user?.name?.toLowerCase().includes(term) ||
request.expand?.requested_user?.email?.toLowerCase().includes(term)
);
}
// Apply sorting
filtered.sort((a, b) => {
let aValue: any = a[sortField as keyof ExtendedEventRequest];
let bValue: any = b[sortField as keyof ExtendedEventRequest];
// Handle special cases
if (sortField === 'requested_user') {
aValue = a.expand?.requested_user?.name || '';
bValue = b.expand?.requested_user?.name || '';
}
// Handle date fields
if (sortField === 'created' || sortField === 'updated' ||
sortField === 'start_date_time' || sortField === 'end_date_time') {
aValue = new Date(aValue || '').getTime();
bValue = new Date(bValue || '').getTime();
}
// Compare values based on sort direction
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
setFilteredRequests(filtered);
};
// Update event request status
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
try {
const update = Update.getInstance();
const result = await update.updateField('event_request', id, 'status', status);
// Find the event request to get its name
const eventRequest = eventRequests.find(req => req.id === id);
const eventName = eventRequest?.name || 'Event';
// Update local state
setEventRequests(prev =>
prev.map(request =>
request.id === id ? { ...request, status } : request
)
);
setFilteredRequests(prev =>
prev.map(request =>
request.id === id ? { ...request, status } : request
)
);
// Update selected request if open
if (selectedRequest && selectedRequest.id === id) {
setSelectedRequest({ ...selectedRequest, status });
}
// Force sync to update IndexedDB
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
// Show success toast with event name
toast.success(`"${eventName}" status updated to ${status}`);
} catch (error) {
// Find the event request to get its name
const eventRequest = eventRequests.find(req => req.id === id);
const eventName = eventRequest?.name || 'Event';
console.error('Error updating status:', error);
toast.error(`Failed to update status for "${eventName}"`);
throw error; // Re-throw the error to be caught by the caller
}
};
// Format date for display
const formatDate = (dateString: string) => {
if (!dateString) return 'Not specified';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
};
// Get status badge class based on status
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
if (!status) return 'badge-warning';
switch (status) {
case 'completed':
return 'badge-success';
case 'declined':
return 'badge-error';
case 'pending':
return 'badge-warning';
case 'submitted':
return 'badge-info';
default:
return 'badge-warning';
}
};
// Open modal with event request details
const openDetailModal = (request: ExtendedEventRequest) => {
setSelectedRequest(request);
setIsModalOpen(true);
};
// Close modal
const closeModal = () => {
setIsModalOpen(false);
setSelectedRequest(null);
};
// Open update modal
const openUpdateModal = (request: ExtendedEventRequest) => {
setRequestToUpdate(request);
setIsUpdateModalOpen(true);
};
// Close update modal
const closeUpdateModal = () => {
setIsUpdateModalOpen(false);
setRequestToUpdate(null);
};
// Update status and close modal
const handleUpdateStatus = async (status: "submitted" | "pending" | "completed" | "declined") => {
if (requestToUpdate) {
try {
await updateEventRequestStatus(requestToUpdate.id, status);
// Toast is now shown in updateEventRequestStatus
closeUpdateModal();
} catch (error) {
console.error('Error in handleUpdateStatus:', error);
// Toast is now shown in updateEventRequestStatus
// Keep modal open so user can try again
}
}
};
// Handle sort change
const handleSortChange = (field: string) => {
if (sortField === field) {
// Toggle direction if same field
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
// Set new field and default to descending
setSortField(field);
setSortDirection('desc');
}
};
// Apply filters when filter state changes
useEffect(() => {
applyFilters();
}, [statusFilter, searchTerm, sortField, sortDirection]);
// Check authentication and refresh token if needed
useEffect(() => {
const checkAuth = async () => {
const auth = Authentication.getInstance();
// Check if we're authenticated
if (!auth.isAuthenticated()) {
console.log("Authentication check failed - attempting to continue anyway");
// Don't show error or redirect immediately - try to refresh first
try {
// Try to refresh event requests anyway - the token might be valid
await refreshEventRequests();
} catch (err) {
console.error("Failed to refresh after auth check:", err);
toast.error("Authentication error. Please log in again.");
// Only redirect if refresh fails
setTimeout(() => {
window.location.href = "/login";
}, 2000);
}
} else {
console.log("Authentication check passed");
}
};
checkAuth();
}, []);
// Auto refresh on component mount
useEffect(() => {
refreshEventRequests();
}, []);
// Listen for tab visibility changes and refresh data when tab becomes visible
useEffect(() => {
const handleTabVisible = () => {
console.log("Tab became visible, refreshing event requests...");
refreshEventRequests();
};
// Add event listener for custom dashboardTabVisible event
document.addEventListener("dashboardTabVisible", handleTabVisible);
// Clean up event listener on component unmount
return () => {
document.removeEventListener("dashboardTabVisible", handleTabVisible);
};
}, []);
if (filteredRequests.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
>
<div className="flex flex-col items-center justify-center py-6">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
<p className="text-base-content/60 mb-6 max-w-md">
{statusFilter !== 'all' || searchTerm
? 'No event requests match your current filters. Try adjusting your search criteria.'
: 'There are no event requests in the system yet.'}
</p>
<div className="flex gap-3">
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
{(statusFilter !== 'all' || searchTerm) && (
<button
className="btn btn-outline btn-sm gap-2"
onClick={() => {
setStatusFilter('all');
setSearchTerm('');
}}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear Filters
</button>
)}
</div>
</div>
</motion.div>
);
}
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-6"
style={{ minHeight: "500px" }}
>
{/* Filters and controls */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 mb-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
<div className="form-control w-full sm:w-auto">
<div className="input-group">
<input
type="text"
placeholder="Search events..."
className="input input-bordered w-full sm:w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<button className="btn btn-square">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
</div>
<select
className="select select-bordered w-full sm:w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
<option value="declined">Declined</option>
</select>
</div>
<div className="flex items-center gap-3 w-full lg:w-auto justify-between sm:justify-end">
<span className="text-sm text-gray-400">
{filteredRequests.length} {filteredRequests.length === 1 ? 'request' : 'requests'} found
</span>
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
</div>
</div>
{/* Event requests table */}
<div
className="rounded-xl shadow-sm overflow-x-auto"
style={{
maxHeight: "unset",
height: "auto"
}}
>
<table className="table table-zebra w-full">
<thead className="bg-base-300/50">
<tr>
<th
className="cursor-pointer hover:bg-base-300"
onClick={() => handleSortChange('name')}
>
<div className="flex items-center gap-1">
Event Name
{sortField === 'name' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
onClick={() => handleSortChange('start_date_time')}
>
<div className="flex items-center gap-1">
Date
{sortField === 'start_date_time' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th
className="cursor-pointer hover:bg-base-300"
onClick={() => handleSortChange('requested_user')}
>
<div className="flex items-center gap-1">
Requested By
{sortField === 'requested_user' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th className="hidden lg:table-cell">PR Materials</th>
<th className="hidden lg:table-cell">AS Funding</th>
<th
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
onClick={() => handleSortChange('created')}
>
<div className="flex items-center gap-1">
Submitted
{sortField === 'created' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th
className="cursor-pointer hover:bg-base-300"
onClick={() => handleSortChange('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredRequests.map((request) => (
<tr key={request.id} className="hover">
<td className="font-medium">{request.name}</td>
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
<td>
<div className="flex flex-col">
<span>{request.expand?.requested_user?.name || 'Unknown'}</span>
<span className="text-xs text-gray-400">{request.expand?.requested_user?.email}</span>
</div>
</td>
<td className="hidden lg:table-cell">
{request.flyers_needed ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td className="hidden lg:table-cell">
{request.as_funding_required ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td className="hidden md:table-cell">{formatDate(request.created)}</td>
<td>
<span className={`badge ${getStatusBadge(request.status)}`}>
{request.status || 'Pending'}
</span>
</td>
<td>
<div className="flex items-center gap-2">
<button
className="btn btn-sm btn-outline"
onClick={() => openUpdateModal(request)}
>
Update
</button>
<button
className="btn btn-sm btn-ghost"
onClick={() => openDetailModal(request)}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
{/* Event request details modal - Now outside the main component div */}
{isModalOpen && selectedRequest && (
<AnimatePresence>
<EventRequestDetails
request={selectedRequest}
onClose={closeModal}
onStatusChange={updateEventRequestStatus}
/>
</AnimatePresence>
)}
{/* Update status modal */}
{isUpdateModalOpen && requestToUpdate && (
<AnimatePresence>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="bg-base-200 rounded-lg shadow-xl w-full max-w-md overflow-hidden flex flex-col"
>
{/* Header */}
<div className="bg-base-300 p-4 flex justify-between items-center">
<h3 className="text-xl font-bold">Update Status</h3>
<button
className="btn btn-sm btn-circle"
onClick={closeUpdateModal}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="mb-4">
Update status for event: <span className="font-semibold">{requestToUpdate.name}</span>
</p>
<div className="flex flex-col gap-3">
<button
className="btn btn-outline w-full justify-start"
onClick={() => handleUpdateStatus("pending")}
>
<span className="badge badge-warning mr-2">Pending</span>
Mark as Pending
</button>
<button
className="btn btn-outline w-full justify-start"
onClick={() => handleUpdateStatus("completed")}
>
<span className="badge badge-success mr-2">Completed</span>
Mark as Completed
</button>
<button
className="btn btn-outline w-full justify-start"
onClick={() => handleUpdateStatus("declined")}
>
<span className="badge badge-error mr-2">Declined</span>
Mark as Declined
</button>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-base-300 flex justify-end">
<button
className="btn btn-ghost"
onClick={closeUpdateModal}
>
Cancel
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
)}
</>
);
};
export default EventRequestManagementTable;

View file

@ -0,0 +1,7 @@
---
import ReimbursementManagementPortal from "./reimbursement/ReimbursementManagementPortal";
---
<div class="w-full">
<ReimbursementManagementPortal client:load />
</div>

View file

@ -0,0 +1,3 @@
---
---

View file

@ -0,0 +1,40 @@
---
import { Icon } from "astro-icon/components";
import ShowProfileLogs from "./ProfileSection/ShowProfileLogs";
import { Stats } from "./ProfileSection/Stats";
---
<div id="" class="">
<div class="mb-6">
<h2 class="text-2xl font-bold">Dashboard Overview</h2>
<p class="opacity-70">Welcome to your IEEE UCSD dashboard</p>
</div>
<Stats client:load />
<!-- Dashboard Content -->
<div
class="card bg-base-100 shadow-lg border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
>
<div class="card-body">
<h3 class="card-title text-xl font-bold flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:eye" class="h-5 w-5" />
</div>
Recent Activity
<div class="badge badge-ghost text-xs font-normal">
Real-time updates
</div>
</h3>
<p class="text-sm opacity-70">
Track your recent interactions with the IEEE UCSD platform
</p>
<div class="divider"></div>
<div class="">
<div class="min-h-[300px]">
<ShowProfileLogs client:load />
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,579 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import debounce from 'lodash/debounce';
import type { Log } from "../../../schemas/pocketbase";
import { SendLog } from "../../../scripts/pocketbase/SendLog";
const LOGS_PER_PAGE = 5;
export default function ShowProfileLogs() {
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalLogs, setTotalLogs] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [allLogs, setAllLogs] = useState<Log[]>([]);
const [isFetchingAll, setIsFetchingAll] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
// Auto-refresh logs every 30 seconds if enabled
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
if (!isFetchingAll) {
fetchLogs(true);
}
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [autoRefresh, isFetchingAll]);
const fetchLogs = async (skipCache = false) => {
setLoading(true);
setError(null);
const auth = Authentication.getInstance();
const currentUser = auth.getPocketBase().authStore.model;
const userId = currentUser?.id;
if (!userId) {
setError("Not authenticated");
setLoading(false);
return;
}
try {
setIsFetchingAll(true);
console.log("Fetching logs for user:", userId);
// Use DataSyncService to fetch logs
const dataSync = DataSyncService.getInstance();
// First sync logs for this user
await dataSync.syncCollection(
Collections.LOGS,
`user = "${userId}"`,
"-created",
{ expand: "user" }
);
// Then get all logs from IndexedDB
const fetchedLogs = await dataSync.getData<Log>(
Collections.LOGS,
false, // Don't force sync again
`user = "${userId}"`,
"-created"
);
console.log("Fetched logs:", fetchedLogs.length);
if (fetchedLogs.length === 0) {
// If no logs found, try to fetch directly from PocketBase
console.log("No logs found in IndexedDB, trying direct fetch from PocketBase");
try {
const sendLog = SendLog.getInstance();
const directLogs = await sendLog.getUserLogs(userId);
console.log("Direct fetch logs:", directLogs.length);
if (directLogs.length > 0) {
setAllLogs(directLogs);
setTotalPages(Math.ceil(directLogs.length / LOGS_PER_PAGE));
setTotalLogs(directLogs.length);
} else {
setAllLogs(fetchedLogs);
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
setTotalLogs(fetchedLogs.length);
}
} catch (directError) {
console.error("Failed to fetch logs directly:", directError);
setAllLogs(fetchedLogs);
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
setTotalLogs(fetchedLogs.length);
}
} else {
setAllLogs(fetchedLogs);
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
setTotalLogs(fetchedLogs.length);
}
} catch (error) {
console.error("Failed to fetch logs:", error);
setError("Error loading activity");
} finally {
setLoading(false);
setIsFetchingAll(false);
}
};
// Memoized search function
const filteredLogs = useMemo(() => {
if (!allLogs.length) return [];
if (!searchQuery.trim()) {
// When not searching, return only the current page of logs
const startIndex = (currentPage - 1) * LOGS_PER_PAGE;
const endIndex = startIndex + LOGS_PER_PAGE;
return allLogs.slice(startIndex, endIndex);
}
const query = searchQuery.toLowerCase();
return allLogs.filter(log => {
return (
log.message?.toLowerCase().includes(query) ||
log.type?.toLowerCase().includes(query) ||
log.part?.toLowerCase().includes(query) ||
(log.created && new Date(log.created).toLocaleString().toLowerCase().includes(query))
);
});
}, [searchQuery, allLogs, currentPage]);
// Update displayed logs whenever filtered results change
useEffect(() => {
setLogs(filteredLogs);
console.log("Filtered logs updated:", filteredLogs.length, "logs");
}, [filteredLogs]);
// Debounced search handler
const debouncedSearch = useCallback(
debounce((query: string) => {
setSearchQuery(query);
// Reset to first page when searching
if (query.trim()) {
setCurrentPage(1);
}
}, 300),
[]
);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSearch(event.target.value);
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
const handleRefresh = () => {
fetchLogs(true);
};
useEffect(() => {
const loadLogsWithRetry = async () => {
try {
await fetchLogs();
// Wait a moment for state to update
setTimeout(async () => {
// Check if logs were loaded
if (allLogs.length === 0) {
console.log("No logs found after initial fetch, trying direct fetch");
await directFetchLogs();
}
}, 1000);
} catch (error) {
console.error("Failed to load logs with retry:", error);
}
};
loadLogsWithRetry();
checkLogsExist();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Check if there are any logs in the database at all
const checkLogsExist = async () => {
try {
const auth = Authentication.getInstance();
const pb = auth.getPocketBase();
// Check if the logs collection exists and has any records
const result = await pb.collection(Collections.LOGS).getList(1, 1);
console.log("Logs collection check:", {
totalItems: result.totalItems,
page: result.page,
perPage: result.perPage,
totalPages: result.totalPages
});
} catch (error) {
console.error("Failed to check logs collection:", error);
}
};
// Calculate log statistics
const logStats = useMemo(() => {
if (!allLogs.length) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const todayLogs = allLogs.filter(log => new Date(log.created) >= today);
const weekLogs = allLogs.filter(log => new Date(log.created) >= lastWeek);
// Count by type
const typeCount: Record<string, number> = {};
allLogs.forEach(log => {
const type = log.type || 'unknown';
typeCount[type] = (typeCount[type] || 0) + 1;
});
return {
total: allLogs.length,
today: todayLogs.length,
week: weekLogs.length,
types: typeCount
};
}, [allLogs]);
// Direct fetch from PocketBase as a fallback
const directFetchLogs = async () => {
try {
setLoading(true);
setError(null);
const auth = Authentication.getInstance();
const pb = auth.getPocketBase();
const userId = auth.getPocketBase().authStore.model?.id;
if (!userId) {
setError("Not authenticated");
setLoading(false);
return;
}
console.log("Direct fetching logs for user:", userId);
// Fetch logs directly from PocketBase
const result = await pb.collection(Collections.LOGS).getList<Log>(1, 100, {
filter: `user = "${userId}"`,
sort: "-created",
expand: "user"
});
console.log("Direct fetch result:", {
totalItems: result.totalItems,
items: result.items.length
});
if (result.items.length > 0) {
setAllLogs(result.items);
setTotalPages(Math.ceil(result.items.length / LOGS_PER_PAGE));
setTotalLogs(result.items.length);
}
} catch (error) {
console.error("Failed to direct fetch logs:", error);
setError("Error loading activity");
} finally {
setLoading(false);
}
};
// Add a button to try direct fetch
const renderDirectFetchButton = () => (
<button
className="btn btn-sm btn-outline mt-4"
onClick={directFetchLogs}
>
Try Direct Fetch
</button>
);
if (loading && !allLogs.length) {
return (
<p className="text-base-content/70 flex items-center gap-2">
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{isFetchingAll ? 'Fetching your activity...' : 'Loading activity...'}
</p>
);
}
if (error) {
return (
<p className="text-base-content/70 flex items-center gap-2">
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{error}
</p>
);
}
// Debug logs
console.log("Render state:", {
logsLength: logs.length,
allLogsLength: allLogs.length,
searchQuery,
loading,
currentPage
});
if (allLogs.length === 0 && !searchQuery && !loading) {
return (
<div>
<p className="text-base-content/70 flex items-center gap-2 mb-4">
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
No recent activity to display.
</p>
<div className="flex gap-2">
<button
className="btn btn-sm btn-primary"
onClick={async () => {
try {
const auth = Authentication.getInstance();
const userId = auth.getPocketBase().authStore.model?.id;
if (!userId) return;
const sendLog = SendLog.getInstance();
await sendLog.send(
"create",
"test",
"Test log created for debugging",
userId
);
console.log("Created test log");
setTimeout(() => fetchLogs(true), 1000);
} catch (error) {
console.error("Failed to create test log:", error);
}
}}
>
Create Test Log
</button>
<button
className="btn btn-sm btn-outline"
onClick={directFetchLogs}
>
Try Direct Fetch
</button>
</div>
</div>
);
}
return (
<div>
{/* Activity Summary */}
{logStats && !searchQuery && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="stat bg-base-200 rounded-lg p-4">
<div className="stat-title">Today</div>
<div className="stat-value">{logStats.today}</div>
<div className="stat-desc">Activities recorded today</div>
</div>
<div className="stat bg-base-200 rounded-lg p-4">
<div className="stat-title">This Week</div>
<div className="stat-value">{logStats.week}</div>
<div className="stat-desc">Activities in the last 7 days</div>
</div>
<div className="stat bg-base-200 rounded-lg p-4">
<div className="stat-title">Total</div>
<div className="stat-value">{logStats.total}</div>
<div className="stat-desc">All-time activities</div>
</div>
</div>
)}
{/* Search and Refresh Controls */}
<div className="flex gap-4 mb-4">
<div className="flex-1">
<input
type="text"
placeholder="Search activity..."
onChange={handleSearch}
className="input input-bordered w-full"
/>
</div>
<div className="dropdown dropdown-end dropdown-hover">
<button
onClick={handleRefresh}
className={`btn btn-ghost btn-square ${isFetchingAll ? 'loading' : ''}`}
title="Refresh logs"
disabled={isFetchingAll}
>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-3.183a.75.75 0 100 1.5h4.992a.75.75 0 00.75-.75V4.356a.75.75 0 00-1.5 0v3.18l-1.9-1.9A9 9 0 003.306 9.67a.75.75 0 101.45.388zm15.408 3.352a.75.75 0 00-.919.53 7.5 7.5 0 01-12.548 3.364l-1.902-1.903h3.183a.75.75 0 000-1.5H2.984a.75.75 0 00-.75.75v4.992a.75.75 0 001.5 0v-3.18l1.9 1.9a9 9 0 0015.059-4.035.75.75 0 00-.53-.918z" clipRule="evenodd" />
</svg>
</button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li>
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className="flex justify-between"
>
<span>Auto-refresh</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-sm"
checked={autoRefresh}
onChange={() => { }}
/>
</button>
</li>
<li><button onClick={directFetchLogs}>Direct fetch from server</button></li>
</ul>
</div>
</div>
{isFetchingAll && (
<div className="mb-4">
<p className="text-sm opacity-70">Fetching all activity, please wait...</p>
</div>
)}
{/* Search Results Message */}
{searchQuery && (
<p className="text-sm opacity-70 mb-4">
Found {logs.length} results for "{searchQuery}"
</p>
)}
{/* Logs Display */}
<div className="space-y-2">
{logs.map((log) => (
<div key={log.id} className="flex items-start gap-4 p-4 rounded-lg hover:bg-base-200 transition-colors duration-200">
<div className="flex-shrink-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${getLogTypeColor(log.type)}`}>
{getLogTypeIcon(log.type)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-base font-medium">{log.message}</p>
<div className="flex items-center gap-2 mt-1">
{log.part && (
<span className="badge badge-sm">{log.part}</span>
)}
<p className="text-sm opacity-50">
{new Date(log.created).toLocaleString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</div>
))}
</div>
{/* Pagination Controls */}
{!searchQuery && totalLogs > LOGS_PER_PAGE && (
<div className="flex justify-between items-center mt-6 pt-4 border-t border-base-200">
<div className="flex items-center gap-2">
<span className="text-sm opacity-70">
Showing {totalLogs ? (currentPage - 1) * LOGS_PER_PAGE + 1 : 0}-{Math.min(currentPage * LOGS_PER_PAGE, totalLogs)} of {totalLogs} results
</span>
</div>
<div className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={currentPage === 1}
className="btn btn-sm btn-ghost"
>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Previous
</button>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="btn btn-sm btn-ghost"
>
Next
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
function getLogTypeColor(type: string): string {
switch (type?.toLowerCase()) {
case 'error':
return 'bg-error/10 text-error';
case 'update':
return 'bg-info/10 text-info';
case 'delete':
return 'bg-warning/10 text-warning';
case 'create':
return 'bg-success/10 text-success';
case 'login':
case 'logout':
return 'bg-primary/10 text-primary';
default:
return 'bg-base-300/50 text-base-content';
}
}
function getLogTypeIcon(type: string) {
switch (type?.toLowerCase()) {
case 'error':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
);
case 'update':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-8.4 8.4a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32l8.4-8.4z" />
<path d="M5.25 5.25a3 3 0 00-3 3v10.5a3 3 0 003 3h10.5a3 3 0 003-3V13.5a.75.75 0 00-1.5 0v5.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5V8.25a1.5 1.5 0 011.5-1.5h5.25a.75.75 0 000-1.5H5.25z" />
</svg>
);
case 'delete':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M16.5 4.478v.227a48.816 48.816 0 013.878.512.75.75 0 11-.256 1.478l-.209-.035-1.005 13.07a3 3 0 01-2.991 2.77H8.084a3 3 0 01-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 01-.256-1.478A48.567 48.567 0 017.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 013.369 0c1.603.051 2.815 1.387 2.815 2.951zm-6.136-1.452a51.196 51.196 0 013.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 00-6 0v-.113c0-.794.609-1.428 1.364-1.452zm-.355 5.945a.75.75 0 10-1.5.058l.347 9a.75.75 0 101.499-.058l-.346-9zm5.48.058a.75.75 0 10-1.498-.058l-.347 9a.75.75 0 001.5.058l.345-9z" clipRule="evenodd" />
</svg>
);
case 'create':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clipRule="evenodd" />
</svg>
);
case 'login':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm10.72 4.72a.75.75 0 011.06 0l3 3a.75.75 0 010 1.06l-3 3a.75.75 0 11-1.06-1.06l1.72-1.72H9a.75.75 0 010-1.5h10.94l-1.72-1.72a.75.75 0 010-1.06z" clipRule="evenodd" />
</svg>
);
case 'logout':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm5.03 4.72a.75.75 0 010 1.06l-1.72 1.72h10.94a.75.75 0 010 1.5H10.81l1.72 1.72a.75.75 0 11-1.06 1.06l-3-3a.75.75 0 010-1.06l3-3a.75.75 0 011.06 0z" clipRule="evenodd" />
</svg>
);
default:
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm1-13h-2v6l5.25 3.15.75-1.23-4-2.37z" />
</svg>
);
}
}

View file

@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, Log, User } from "../../../schemas/pocketbase";
import { Get } from "../../../scripts/pocketbase/Get";
import type { EventAttendee } from "../../../schemas/pocketbase";
import { Update } from "../../../scripts/pocketbase/Update";
// Extended User interface with points property
interface ExtendedUser extends User {
points?: number;
member_type?: string;
}
export function Stats() {
const [eventsAttended, setEventsAttended] = useState(0);
const [loyaltyPoints, setLoyaltyPoints] = useState(0);
const [pointsChange, setPointsChange] = useState("No activity");
const [quarterlyPoints, setQuarterlyPoints] = useState(0); // Points earned this quarter
const [membershipStatus, setMembershipStatus] = useState("Member");
const [memberSince, setMemberSince] = useState<string | null>(null);
const [upcomingEvents, setUpcomingEvents] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [user, setUser] = useState<ExtendedUser | null>(null);
const [pointsEarned, setPointsEarned] = useState(0);
const [attendancePercentage, setAttendancePercentage] = useState(0);
// Helper function to get the start date of the current quarter
const getCurrentQuarterStartDate = (): Date => {
const now = new Date();
const currentMonth = now.getMonth();
let quarterStartMonth = 0;
// Determine the start month of the current quarter
if (currentMonth >= 0 && currentMonth <= 2) {
quarterStartMonth = 0; // Q1: Jan-Mar
} else if (currentMonth >= 3 && currentMonth <= 5) {
quarterStartMonth = 3; // Q2: Apr-Jun
} else if (currentMonth >= 6 && currentMonth <= 8) {
quarterStartMonth = 6; // Q3: Jul-Sep
} else {
quarterStartMonth = 9; // Q4: Oct-Dec
}
return new Date(now.getFullYear(), quarterStartMonth, 1);
};
useEffect(() => {
const fetchStats = async () => {
try {
setIsLoading(true);
setError(null);
const auth = Authentication.getInstance();
const get = Get.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
setError("User not logged in");
return;
}
const userId = currentUser.id;
// Get user data
const userData = await get.getOne<ExtendedUser>("users", userId);
if (!userData) {
setError("Failed to load user data");
return;
}
// Set user data
setUser(userData);
// Get events attended by the user
const attendedEvents = await get.getList<EventAttendee>(
"event_attendees",
1,
1000,
`user="${userId}"`
);
setEventsAttended(attendedEvents.totalItems);
// Get user points - either from the user record or calculate from attendees
let totalPoints = 0;
// Calculate quarterly points
const quarterStartDate = getCurrentQuarterStartDate();
let pointsThisQuarter = 0;
// If user has points field, use that for total points
if (currentUser && currentUser.points !== undefined) {
totalPoints = currentUser.points;
// Still need to calculate quarterly points from attendees
attendedEvents.items.forEach(attendee => {
const checkinDate = new Date(attendee.time_checked_in);
if (checkinDate >= quarterStartDate) {
pointsThisQuarter += attendee.points_earned || 0;
}
});
} else {
// Calculate both total and quarterly points from attendees
attendedEvents.items.forEach(attendee => {
const points = attendee.points_earned || 0;
totalPoints += points;
const checkinDate = new Date(attendee.time_checked_in);
if (checkinDate >= quarterStartDate) {
pointsThisQuarter += points;
}
});
// Update the user record with calculated points if needed
if (currentUser) {
try {
const update = Update.getInstance();
await update.updateFields(Collections.USERS, currentUser.id, {
points: totalPoints
});
} catch (error) {
console.error("Error updating user points:", error);
}
}
}
setPointsEarned(totalPoints);
setLoyaltyPoints(totalPoints);
setQuarterlyPoints(pointsThisQuarter);
// Get current quarter name
const now = new Date();
const currentMonth = now.getMonth();
let quarterName = "";
if (currentMonth >= 0 && currentMonth <= 2) {
quarterName = "Q1";
} else if (currentMonth >= 3 && currentMonth <= 5) {
quarterName = "Q2";
} else if (currentMonth >= 6 && currentMonth <= 8) {
quarterName = "Q3";
} else {
quarterName = "Q4";
}
setPointsChange(`${pointsThisQuarter} pts in ${quarterName}`);
// Get all events to calculate percentage
const allEvents = await get.getList<Event>("events", 1, 1000);
if (allEvents.totalItems > 0) {
const percentage = (attendedEvents.totalItems / allEvents.totalItems) * 100;
setAttendancePercentage(Math.round(percentage));
} else {
setAttendancePercentage(0);
}
} catch (error) {
console.error("Error fetching stats:", error);
setError("Failed to load stats");
} finally {
setIsLoading(false);
}
};
fetchStats();
}, []);
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200">
<div className="stat">
<div className="stat-title skeleton h-4 w-32 mb-2"></div>
<div className="stat-value skeleton h-8 w-16"></div>
<div className="stat-desc mt-1">
<div className="skeleton h-4 w-24"></div>
</div>
</div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat">
<div className="stat-title font-medium opacity-80">Events Attended</div>
<div className="stat-value text-primary">{eventsAttended}</div>
<div className="stat-desc flex items-center gap-2 mt-1">
<div className="badge badge-primary badge-sm">Since joining</div>
</div>
</div>
</div>
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat">
<div className="stat-title font-medium opacity-80">Loyalty Points</div>
<div className="stat-value text-secondary">{loyaltyPoints}</div>
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
<div className="flex items-center justify-between w-full">
<div className="badge badge-secondary badge-sm">{quarterlyPoints} pts this quarter</div>
<div className="text-xs opacity-70">Total points</div>
</div>
</div>
</div>
</div>
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-accent transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat">
<div className="stat-title font-medium opacity-80">Upcoming Events</div>
<div className="stat-value text-accent">{upcomingEvents}</div>
<div className="stat-desc flex items-center gap-2 mt-1">
<div className="badge badge-accent badge-sm">Available to attend</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
---
import ReimbursementForm from "./reimbursement/ReimbursementForm";
import ReimbursementList from "./reimbursement/ReimbursementList";
---
<div class="space-y-6">
<div class="mb-6">
<h2 class="text-2xl font-bold">Reimbursement</h2>
<p class="opacity-70">Manage your reimbursement requests</p>
</div>
<div class="tabs tabs-boxed">
<button class="tab tab-active" data-tab="list">My Requests</button>
<button class="tab" data-tab="form">New Request</button>
</div>
<div id="reimbursementContent">
<div id="listTab" class="tab-content block">
<ReimbursementList client:load />
</div>
<div id="formTab" class="tab-content hidden">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<ReimbursementForm client:load />
</div>
</div>
</div>
</div>
</div>
<script>
// Tab switching logic
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-content");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const targetTab = tab.getAttribute("data-tab");
// Update tab states
tabs.forEach((t) => t.classList.remove("tab-active"));
tab.classList.add("tab-active");
// Update content visibility
tabContents.forEach((content) => {
if (content.id === `${targetTab}Tab`) {
content.classList.remove("hidden");
content.classList.add("block");
} else {
content.classList.remove("block");
content.classList.add("hidden");
}
});
});
});
</script>

View file

@ -0,0 +1,102 @@
---
import { Icon } from "astro-icon/components";
import UserProfileSettings from "./SettingsSection/UserProfileSettings";
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
import NotificationSettings from "./SettingsSection/NotificationSettings";
import DisplaySettings from "./SettingsSection/DisplaySettings";
---
<div id="settings-section" class="">
<div class="mb-6">
<h2 class="text-2xl font-bold">Settings</h2>
<p class="opacity-70">Manage your account settings and preferences</p>
</div>
<!-- Profile Settings Card -->
<div
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:user" class="h-5 w-5" />
</div>
Profile Information
</h3>
<p class="text-sm opacity-70 mb-4">
Update your personal information and profile details
</p>
<div class="divider"></div>
<UserProfileSettings client:load />
</div>
</div>
<!-- Account Security Settings Card -->
<div
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:lock-closed" class="h-5 w-5" />
</div>
Account Security
</h3>
<p class="text-sm opacity-70 mb-4">
Manage your account security settings and authentication options
</p>
<div class="divider"></div>
<AccountSecuritySettings client:load />
</div>
</div>
<!-- Notification Settings Card -->
<div
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6 relative group"
>
<!-- Coming Soon Overlay -->
<div
class="absolute inset-0 bg-base-300 bg-opacity-90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10 rounded-xl"
>
<div class="text-center">
<h4 class="text-xl font-bold">Coming Soon</h4>
<p class="text-sm opacity-70">
Notification settings will be available in a future update
</p>
</div>
</div>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:bell" class="h-5 w-5" />
</div>
Notification Preferences
</h3>
<p class="text-sm opacity-70 mb-4">
Customize how and when you receive notifications
</p>
<div class="divider"></div>
<NotificationSettings client:load />
</div>
</div>
<!-- Display Settings Card -->
<div
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
</div>
Display Settings
</h3>
<p class="text-sm opacity-70 mb-4">
Customize your dashboard appearance and display preferences
</p>
<div class="divider"></div>
<DisplaySettings client:load />
</div>
</div>
</div>

View file

@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog';
import { toast } from 'react-hot-toast';
export default function AccountSecuritySettings() {
const auth = Authentication.getInstance();
const logger = SendLog.getInstance();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [sessionInfo, setSessionInfo] = useState({
lastLogin: '',
browser: '',
device: '',
});
useEffect(() => {
const checkAuth = () => {
const authenticated = auth.isAuthenticated();
setIsAuthenticated(authenticated);
if (authenticated) {
const user = auth.getCurrentUser();
if (user) {
// Get last login time
const lastLogin = user.last_login || user.updated;
// Get browser and device info
const userAgent = navigator.userAgent;
const browser = detectBrowser(userAgent);
const device = detectDevice(userAgent);
setSessionInfo({
lastLogin: formatDate(lastLogin),
browser,
device,
});
}
}
setLoading(false);
};
checkAuth();
}, []);
const handleLogout = async () => {
try {
await logger.send('logout', 'auth', 'User manually logged out from settings page');
await auth.logout();
window.location.href = '/';
} catch (error) {
console.error('Error during logout:', error);
toast.error('Failed to log out. Please try again.');
}
};
const detectBrowser = (userAgent: string): string => {
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
if (userAgent.indexOf('Safari') > -1) return 'Safari';
if (userAgent.indexOf('Firefox') > -1) return 'Firefox';
if (userAgent.indexOf('MSIE') > -1 || userAgent.indexOf('Trident') > -1) return 'Internet Explorer';
if (userAgent.indexOf('Edge') > -1) return 'Edge';
return 'Unknown Browser';
};
const detectDevice = (userAgent: string): string => {
if (/Android/i.test(userAgent)) return 'Android Device';
if (/iPhone|iPad|iPod/i.test(userAgent)) return 'iOS Device';
if (/Windows/i.test(userAgent)) return 'Windows Device';
if (/Mac/i.test(userAgent)) return 'Mac Device';
if (/Linux/i.test(userAgent)) return 'Linux Device';
return 'Unknown Device';
};
const formatDate = (dateString: string): string => {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
}).format(date);
};
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="p-4 text-error bg-error bg-opacity-10 rounded-lg">
<span>You must be logged in to access this page.</span>
</div>
);
}
return (
<div>
<div className="space-y-6">
{/* Current Session Information */}
<div className="bg-base-200 p-4 rounded-lg">
<h4 className="font-semibold text-lg mb-2">Current Session</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm opacity-70">Last Login</p>
<p className="font-medium">{sessionInfo.lastLogin}</p>
</div>
<div>
<p className="text-sm opacity-70">Browser</p>
<p className="font-medium">{sessionInfo.browser}</p>
</div>
<div>
<p className="text-sm opacity-70">Device</p>
<p className="font-medium">{sessionInfo.device}</p>
</div>
</div>
</div>
{/* Authentication Options */}
<div>
<h4 className="font-semibold text-lg mb-2">Authentication Options</h4>
<p className="text-sm opacity-70 mb-4">
IEEE UCSD uses Single Sign-On (SSO) for authentication.
Password management is handled through your IEEEUCSD account.
</p>
<p className="text-sm text-info p-3 bg-info bg-opacity-10 rounded-lg">
To change your password, please use the "Forgot Password" option on the login page.
</p>
</div>
{/* Account Actions */}
<div>
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
<div className="space-y-4">
<button
onClick={handleLogout}
className="btn btn-error btn-outline w-full md:w-auto"
>
Sign Out
</button>
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
If you need to delete your account or have other account-related issues,
please contact an IEEE UCSD administrator.
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,385 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
// Default display preferences
const DEFAULT_DISPLAY_PREFERENCES = {
theme: 'dark',
fontSize: 'medium'
};
// Default accessibility settings
const DEFAULT_ACCESSIBILITY_SETTINGS = {
colorBlindMode: false,
reducedMotion: false
};
export default function DisplaySettings() {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const [theme, setTheme] = useState(DEFAULT_DISPLAY_PREFERENCES.theme);
const [fontSize, setFontSize] = useState(DEFAULT_DISPLAY_PREFERENCES.fontSize);
const [colorBlindMode, setColorBlindMode] = useState(DEFAULT_ACCESSIBILITY_SETTINGS.colorBlindMode);
const [reducedMotion, setReducedMotion] = useState(DEFAULT_ACCESSIBILITY_SETTINGS.reducedMotion);
const [saving, setSaving] = useState(false);
// Load saved preferences on component mount
useEffect(() => {
const loadPreferences = async () => {
try {
// First check localStorage for immediate UI updates
const savedTheme = localStorage.getItem('theme') || DEFAULT_DISPLAY_PREFERENCES.theme;
// Ensure theme is either light or dark
const validTheme = ['light', 'dark'].includes(savedTheme) ? savedTheme : DEFAULT_DISPLAY_PREFERENCES.theme;
const savedFontSize = localStorage.getItem('fontSize') || DEFAULT_DISPLAY_PREFERENCES.fontSize;
const savedColorBlindMode = localStorage.getItem('colorBlindMode') === 'true';
const savedReducedMotion = localStorage.getItem('reducedMotion') === 'true';
setTheme(validTheme);
setFontSize(savedFontSize);
setColorBlindMode(savedColorBlindMode);
setReducedMotion(savedReducedMotion);
// Apply theme to document
document.documentElement.setAttribute('data-theme', validTheme);
// Apply font size
applyFontSize(savedFontSize);
// Apply accessibility settings
if (savedColorBlindMode) {
document.documentElement.classList.add('color-blind-mode');
}
if (savedReducedMotion) {
document.documentElement.classList.add('reduced-motion');
}
// Then check if user has saved preferences in their profile
const user = auth.getCurrentUser();
if (user) {
let needsDisplayPrefsUpdate = false;
let needsAccessibilityUpdate = false;
// Check and handle display preferences
if (user.display_preferences && typeof user.display_preferences === 'string' && user.display_preferences.trim() !== '') {
try {
const userPrefs = JSON.parse(user.display_preferences);
// Only update if values exist and are different from localStorage
if (userPrefs.theme && ['light', 'dark'].includes(userPrefs.theme) && userPrefs.theme !== validTheme) {
setTheme(userPrefs.theme);
localStorage.setItem('theme', userPrefs.theme);
document.documentElement.setAttribute('data-theme', userPrefs.theme);
} else if (!['light', 'dark'].includes(userPrefs.theme)) {
// If theme is not valid, mark for update
needsDisplayPrefsUpdate = true;
}
if (userPrefs.fontSize && userPrefs.fontSize !== savedFontSize) {
setFontSize(userPrefs.fontSize);
localStorage.setItem('fontSize', userPrefs.fontSize);
applyFontSize(userPrefs.fontSize);
}
} catch (e) {
console.error('Error parsing display preferences:', e);
needsDisplayPrefsUpdate = true;
}
} else {
needsDisplayPrefsUpdate = true;
}
// Check and handle accessibility settings
if (user.accessibility_settings && typeof user.accessibility_settings === 'string' && user.accessibility_settings.trim() !== '') {
try {
const accessibilityPrefs = JSON.parse(user.accessibility_settings);
if (typeof accessibilityPrefs.colorBlindMode === 'boolean' &&
accessibilityPrefs.colorBlindMode !== savedColorBlindMode) {
setColorBlindMode(accessibilityPrefs.colorBlindMode);
localStorage.setItem('colorBlindMode', accessibilityPrefs.colorBlindMode.toString());
if (accessibilityPrefs.colorBlindMode) {
document.documentElement.classList.add('color-blind-mode');
} else {
document.documentElement.classList.remove('color-blind-mode');
}
}
if (typeof accessibilityPrefs.reducedMotion === 'boolean' &&
accessibilityPrefs.reducedMotion !== savedReducedMotion) {
setReducedMotion(accessibilityPrefs.reducedMotion);
localStorage.setItem('reducedMotion', accessibilityPrefs.reducedMotion.toString());
if (accessibilityPrefs.reducedMotion) {
document.documentElement.classList.add('reduced-motion');
} else {
document.documentElement.classList.remove('reduced-motion');
}
}
} catch (e) {
console.error('Error parsing accessibility settings:', e);
needsAccessibilityUpdate = true;
}
} else {
needsAccessibilityUpdate = true;
}
// Initialize default settings if needed
if (needsDisplayPrefsUpdate || needsAccessibilityUpdate) {
await initializeDefaultSettings(user.id, needsDisplayPrefsUpdate, needsAccessibilityUpdate);
}
}
} catch (error) {
console.error('Error loading preferences:', error);
toast.error('Failed to load display preferences');
}
};
loadPreferences();
}, []);
// Initialize default settings if not set
const initializeDefaultSettings = async (userId: string, updateDisplayPrefs: boolean, updateAccessibility: boolean) => {
try {
const updateData: any = {};
if (updateDisplayPrefs) {
updateData.display_preferences = JSON.stringify({
theme,
fontSize
});
}
if (updateAccessibility) {
updateData.accessibility_settings = JSON.stringify({
colorBlindMode,
reducedMotion
});
}
if (Object.keys(updateData).length > 0) {
await update.updateFields(Collections.USERS, userId, updateData);
console.log('Initialized default display and accessibility settings');
}
} catch (error) {
console.error('Error initializing default settings:', error);
}
};
// Apply font size to document
const applyFontSize = (size: string) => {
const htmlElement = document.documentElement;
// Remove existing font size classes
htmlElement.classList.remove('text-sm', 'text-base', 'text-lg', 'text-xl');
// Add new font size class
switch (size) {
case 'small':
htmlElement.classList.add('text-sm');
break;
case 'medium':
htmlElement.classList.add('text-base');
break;
case 'large':
htmlElement.classList.add('text-lg');
break;
case 'extra-large':
htmlElement.classList.add('text-xl');
break;
}
};
// Handle theme change
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTheme = e.target.value;
setTheme(newTheme);
// Apply theme to document
document.documentElement.setAttribute('data-theme', newTheme);
// Save to localStorage
localStorage.setItem('theme', newTheme);
};
// Handle font size change
const handleFontSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newSize = e.target.value;
setFontSize(newSize);
// Apply font size
applyFontSize(newSize);
// Save to localStorage
localStorage.setItem('fontSize', newSize);
};
// Handle color blind mode toggle
const handleColorBlindModeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const enabled = e.target.checked;
setColorBlindMode(enabled);
// Apply to document
if (enabled) {
document.documentElement.classList.add('color-blind-mode');
} else {
document.documentElement.classList.remove('color-blind-mode');
}
// Save to localStorage
localStorage.setItem('colorBlindMode', enabled.toString());
};
// Handle reduced motion toggle
const handleReducedMotionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const enabled = e.target.checked;
setReducedMotion(enabled);
// Apply to document
if (enabled) {
document.documentElement.classList.add('reduced-motion');
} else {
document.documentElement.classList.remove('reduced-motion');
}
// Save to localStorage
localStorage.setItem('reducedMotion', enabled.toString());
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const user = auth.getCurrentUser();
if (!user) throw new Error('User not authenticated');
// Save display preferences to user record
const displayPreferences = {
theme,
fontSize
};
// Save accessibility settings to user record
const accessibilitySettings = {
colorBlindMode,
reducedMotion
};
// Update user record
await update.updateFields(
Collections.USERS,
user.id,
{
display_preferences: JSON.stringify(displayPreferences),
accessibility_settings: JSON.stringify(accessibilitySettings)
}
);
// Show success message
toast.success('Display settings saved successfully!');
} catch (error) {
console.error('Error saving display settings:', error);
toast.error('Failed to save display settings to your profile');
} finally {
setSaving(false);
}
};
return (
<div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Theme Settings */}
<div>
<h4 className="font-semibold text-lg mb-2">Theme</h4>
<div className="form-control w-full max-w-xs">
<select
value={theme}
onChange={handleThemeChange}
className="select select-bordered"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label className="label">
<span className="label-text-alt">Select your preferred theme</span>
</label>
</div>
</div>
{/* Font Size Settings */}
<div>
<h4 className="font-semibold text-lg mb-2">Font Size</h4>
<div className="form-control w-full max-w-xs">
<select
value={fontSize}
onChange={handleFontSizeChange}
className="select select-bordered"
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="extra-large">Extra Large</option>
</select>
<label className="label">
<span className="label-text-alt">Select your preferred font size</span>
</label>
</div>
</div>
{/* Accessibility Settings */}
<div>
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
checked={colorBlindMode}
onChange={handleColorBlindModeChange}
className="toggle toggle-primary"
/>
<div>
<span className="label-text font-medium">Color Blind Mode</span>
<p className="text-xs opacity-70">Enhances color contrast and uses color-blind friendly palettes</p>
</div>
</label>
</div>
<div className="form-control mt-2">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
checked={reducedMotion}
onChange={handleReducedMotionChange}
className="toggle toggle-primary"
/>
<div>
<span className="label-text font-medium">Reduced Motion</span>
<p className="text-xs opacity-70">Minimizes animations and transitions</p>
</div>
</label>
</div>
</div>
<p className="text-sm text-info">
These settings are saved to your browser and your IEEE UCSD account. They will be applied whenever you log in.
</p>
<div className="form-control">
<button
type="submit"
className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,233 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
// Default notification preferences
const DEFAULT_NOTIFICATION_PREFERENCES = {
emailNotifications: true,
eventReminders: true,
eventUpdates: true,
reimbursementUpdates: true,
officerAnnouncements: true,
marketingEmails: false
};
export default function NotificationSettings() {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Notification preferences
const [preferences, setPreferences] = useState(DEFAULT_NOTIFICATION_PREFERENCES);
useEffect(() => {
const loadPreferences = async () => {
try {
const user = auth.getCurrentUser();
if (user) {
// If user has notification_preferences, parse and use them
// Otherwise use defaults
if (user.notification_preferences && typeof user.notification_preferences === 'string' && user.notification_preferences.trim() !== '') {
try {
const savedPrefs = JSON.parse(user.notification_preferences);
setPreferences(prev => ({
...prev,
...savedPrefs
}));
} catch (e) {
console.error('Error parsing notification preferences:', e);
// Initialize with defaults and save to user profile
await initializeDefaultPreferences(user.id);
}
} else {
// Initialize with defaults and save to user profile
await initializeDefaultPreferences(user.id);
}
}
} catch (error) {
console.error('Error loading notification preferences:', error);
toast.error('Failed to load notification preferences');
} finally {
setLoading(false);
}
};
loadPreferences();
}, []);
// Initialize default preferences if not set
const initializeDefaultPreferences = async (userId: string) => {
try {
await update.updateFields(
Collections.USERS,
userId,
{ notification_preferences: JSON.stringify(DEFAULT_NOTIFICATION_PREFERENCES) }
);
setPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
console.log('Initialized default notification preferences');
} catch (error) {
console.error('Error initializing default notification preferences:', error);
}
};
const handleToggleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target;
setPreferences(prev => ({
...prev,
[name]: checked
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const user = auth.getCurrentUser();
if (!user) throw new Error('User not authenticated');
// Save preferences as JSON string
await update.updateFields(
Collections.USERS,
user.id,
{ notification_preferences: JSON.stringify(preferences) }
);
toast.success('Notification preferences saved successfully!');
} catch (error) {
console.error('Error saving notification preferences:', error);
toast.error('Failed to save notification preferences');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
return (
<div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="emailNotifications"
className="toggle toggle-primary"
checked={preferences.emailNotifications}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Email Notifications</span>
<p className="text-xs opacity-70">Receive notifications via email</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="eventReminders"
className="toggle toggle-primary"
checked={preferences.eventReminders}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Event Reminders</span>
<p className="text-xs opacity-70">Receive reminders about upcoming events</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="eventUpdates"
className="toggle toggle-primary"
checked={preferences.eventUpdates}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Event Updates</span>
<p className="text-xs opacity-70">Receive updates about events you've registered for</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="reimbursementUpdates"
className="toggle toggle-primary"
checked={preferences.reimbursementUpdates}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Reimbursement Updates</span>
<p className="text-xs opacity-70">Receive updates about your reimbursement requests</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="officerAnnouncements"
className="toggle toggle-primary"
checked={preferences.officerAnnouncements}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Officer Announcements</span>
<p className="text-xs opacity-70">Receive important announcements from IEEE UCSD officers</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="marketingEmails"
className="toggle toggle-primary"
checked={preferences.marketingEmails}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Marketing Emails</span>
<p className="text-xs opacity-70">Receive promotional emails about IEEE UCSD events and opportunities</p>
</div>
</label>
</div>
</div>
<p className="text-sm text-info mt-6 mb-6">
Note: Some critical notifications about your account cannot be disabled.
</p>
<div className="form-control">
<button
type="submit"
className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,247 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections, type User } from '../../../schemas/pocketbase/schema';
import allMajors from '../../../data/allUCSDMajors.txt?raw';
import { toast } from 'react-hot-toast';
export default function UserProfileSettings() {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
major: '',
graduation_year: '',
zelle_information: '',
pid: '',
member_id: ''
});
// Parse the majors list from the text file and sort alphabetically
const majorsList = allMajors
.split('\n')
.filter(major => major.trim() !== '')
.sort((a, b) => a.localeCompare(b));
useEffect(() => {
const loadUserData = async () => {
try {
const currentUser = auth.getCurrentUser();
if (currentUser) {
setUser(currentUser);
setFormData({
name: currentUser.name || '',
email: currentUser.email || '',
major: currentUser.major || '',
graduation_year: currentUser.graduation_year?.toString() || '',
zelle_information: currentUser.zelle_information || '',
pid: currentUser.pid || '',
member_id: currentUser.member_id || ''
});
}
} catch (error) {
console.error('Error loading user data:', error);
toast.error('Failed to load user data. Please try again later.');
} finally {
setLoading(false);
}
};
loadUserData();
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
if (!user) throw new Error('User not authenticated');
const updateData: Partial<User> = {
name: formData.name,
major: formData.major || undefined,
zelle_information: formData.zelle_information || undefined,
pid: formData.pid || undefined,
member_id: formData.member_id || undefined
};
// Only include graduation_year if it's a valid number
if (formData.graduation_year && !isNaN(Number(formData.graduation_year))) {
updateData.graduation_year = Number(formData.graduation_year);
}
await update.updateFields(Collections.USERS, user.id, updateData);
// Update local user state
setUser(prev => prev ? { ...prev, ...updateData } : null);
toast.success('Profile updated successfully!');
} catch (error) {
console.error('Error updating profile:', error);
toast.error('Failed to update profile. Please try again.');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
if (!user) {
return (
<div className="alert alert-error">
<div>
<span>You must be logged in to access this page.</span>
</div>
</div>
);
}
return (
<div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">Full Name</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
className="input input-bordered w-full"
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Email Address</span>
<span className="label-text-alt text-info">Cannot be changed</span>
</label>
<input
type="email"
name="email"
value={formData.email}
className="input input-bordered w-full"
disabled
/>
<label className="label">
<span className="label-text-alt">Email changes must be processed by an administrator</span>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">PID</span>
<span className="label-text-alt text-info">UCSD Student ID</span>
</label>
<input
type="text"
name="pid"
value={formData.pid}
onChange={handleInputChange}
className="input input-bordered w-full"
placeholder="A12345678"
pattern="[A-Za-z][0-9]{8}"
title="PID format: A12345678"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">IEEE Member ID</span>
<span className="label-text-alt text-info">Optional</span>
</label>
<input
type="text"
name="member_id"
value={formData.member_id}
onChange={handleInputChange}
className="input input-bordered w-full"
placeholder="IEEE Membership Number"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Major</span>
</label>
<select
name="major"
value={formData.major}
onChange={handleInputChange}
className="select select-bordered w-full"
>
<option value="">Select a major</option>
{majorsList.map((major, index) => (
<option key={index} value={major}>
{major}
</option>
))}
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Graduation Year</span>
</label>
<input
type="number"
name="graduation_year"
value={formData.graduation_year}
onChange={handleInputChange}
className="input input-bordered w-full"
min="2000"
max="2100"
/>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Zelle Information (for reimbursements)</span>
</label>
<input
type="text"
name="zelle_information"
value={formData.zelle_information}
onChange={handleInputChange}
className="input input-bordered w-full"
placeholder="Email or phone number associated with your Zelle account"
/>
</div>
<div className="form-control mt-6">
<button
type="submit"
className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,99 @@
---
// Sponsor Analytics Component
---
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Analytics Dashboard</h2>
<!-- Metrics Overview -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Resume Downloads</div>
<div class="stat-value text-primary">89</div>
<div class="stat-desc">↗︎ 14 (30 days)</div>
</div>
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Event Attendance</div>
<div class="stat-value">45</div>
<div class="stat-desc">↘︎ 5 (30 days)</div>
</div>
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Student Interactions</div>
<div class="stat-value text-secondary">124</div>
<div class="stat-desc">↗︎ 32 (30 days)</div>
</div>
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-title">Workshop Engagement</div>
<div class="stat-value">92%</div>
<div class="stat-desc">↗︎ 8% (30 days)</div>
</div>
</div>
<!-- Detailed Analytics -->
<div class="grid gap-6 md:grid-cols-2">
<!-- Event Performance -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Event Performance</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Event</th>
<th>Attendance</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tech Talk</td>
<td>32</td>
<td>4.8/5</td>
</tr>
<tr>
<td>Workshop</td>
<td>28</td>
<td>4.6/5</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Resume Analytics -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Resume Analytics</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Major</th>
<th>Downloads</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
<tr>
<td>Computer Science</td>
<td>45</td>
<td>↗︎</td>
</tr>
<tr>
<td>Electrical Engineering</td>
<td>32</td>
<td>↗︎</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,78 @@
---
// Sponsor Dashboard Component
---
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Sponsor Dashboard</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Sponsorship Status -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Sponsorship Status</h3>
<p class="text-primary font-semibold">Active</p>
<p class="text-sm opacity-70">Valid until: Dec 31, 2024</p>
</div>
</div>
<!-- Partnership Level -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Partnership Level</h3>
<p class="text-primary font-semibold">Platinum</p>
<p class="text-sm opacity-70">All benefits included</p>
</div>
</div>
<!-- Quick Actions -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Quick Actions</h3>
<div class="flex gap-2 mt-2">
<button class="btn btn-primary btn-sm"
>Contact Us</button
>
<button class="btn btn-outline btn-sm"
>View Contract</button
>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="mt-6">
<h3 class="text-xl font-semibold mb-4">Recent Activity</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Activity</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>2024-01-15</td>
<td>Resume Book Access</td>
<td
><span class="badge badge-success"
>Completed</span
></td
>
</tr>
<tr>
<td>2024-01-10</td>
<td>Workshop Scheduling</td>
<td
><span class="badge badge-warning">Pending</span
></td
>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,442 @@
import React, { useState } from 'react';
import { Icon } from '@iconify/react';
import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import type { ItemizedExpense } from '../../../schemas/pocketbase';
interface ReceiptFormData {
file: File;
itemized_expenses: ItemizedExpense[];
tax: number;
date: string;
location_name: string;
location_address: string;
notes: string;
}
interface ReceiptFormProps {
onSubmit: (data: ReceiptFormData) => void;
onCancel: () => void;
}
const EXPENSE_CATEGORIES = [
'Travel',
'Meals',
'Supplies',
'Equipment',
'Software',
'Event Expenses',
'Other'
];
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [itemizedExpenses, setItemizedExpenses] = useState<ItemizedExpense[]>([
{ description: '', amount: 0, category: '' }
]);
const [tax, setTax] = useState<number>(0);
const [date, setDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [locationName, setLocationName] = useState<string>('');
const [locationAddress, setLocationAddress] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [error, setError] = useState<string>('');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate file type
if (!selectedFile.type.match('image/*') && selectedFile.type !== 'application/pdf') {
toast.error('Only images and PDF files are allowed');
setError('Only images and PDF files are allowed');
return;
}
// Validate file size (5MB limit)
if (selectedFile.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB');
setError('File size must be less than 5MB');
return;
}
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setError('');
toast.success('File uploaded successfully');
}
};
const addExpenseItem = () => {
setItemizedExpenses([...itemizedExpenses, { description: '', amount: 0, category: '' }]);
};
const removeExpenseItem = (index: number) => {
if (itemizedExpenses.length === 1) return;
setItemizedExpenses(itemizedExpenses.filter((_, i) => i !== index));
};
const handleExpenseItemChange = (index: number, field: keyof ItemizedExpense, value: string | number) => {
const newItems = [...itemizedExpenses];
newItems[index] = {
...newItems[index],
[field]: value
};
setItemizedExpenses(newItems);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
setError('Please upload a receipt');
return;
}
if (!locationName.trim()) {
setError('Location name is required');
return;
}
if (!locationAddress.trim()) {
setError('Location address is required');
return;
}
if (itemizedExpenses.some(item => !item.description || !item.category || item.amount <= 0)) {
setError('All expense items must be filled out completely');
return;
}
onSubmit({
file: file,
itemized_expenses: itemizedExpenses,
tax,
date,
location_name: locationName,
location_address: locationAddress,
notes
});
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-2 gap-6 h-full"
>
{/* Left side - Form */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
>
<form onSubmit={handleSubmit} className="space-y-6">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* File Upload */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Upload Receipt</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="relative">
<input
type="file"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleFileChange}
accept="image/*,.pdf"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-base-content/50">
<Icon icon="heroicons:cloud-arrow-up" className="h-5 w-5" />
</div>
</div>
</motion.div>
{/* Date */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</motion.div>
{/* Location Name */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Location Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={locationName}
onChange={(e) => setLocationName(e.target.value)}
required
/>
</motion.div>
{/* Location Address */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Location Address</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={locationAddress}
onChange={(e) => setLocationAddress(e.target.value)}
required
/>
</motion.div>
{/* Notes */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Notes</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</motion.div>
{/* Itemized Expenses */}
<motion.div variants={itemVariants} className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-lg font-medium">Itemized Expenses</label>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={addExpenseItem}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item
</motion.button>
</div>
<AnimatePresence>
{itemizedExpenses.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
>
<div className="card-body p-4">
<div className="grid gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Description</span>
</label>
<input
type="text"
className="input input-bordered"
value={item.description}
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Category</span>
</label>
<select
className="select select-bordered"
value={item.category}
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
required
>
<option value="">Select category</option>
{EXPENSE_CATEGORIES.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Amount ($)</span>
</label>
<div className="flex items-center space-x-2">
<input
type="number"
className="input input-bordered"
value={item.amount}
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
min="0"
step="0.01"
required
/>
{itemizedExpenses.length > 1 && (
<button
type="button"
className="btn btn-square btn-sm btn-error"
onClick={() => removeExpenseItem(index)}
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
{/* Tax */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Tax Amount ($)</span>
</label>
<input
type="number"
className="input input-bordered focus:input-primary transition-all duration-300"
value={tax}
onChange={(e) => setTax(Number(e.target.value))}
min="0"
step="0.01"
/>
</motion.div>
{/* Total */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<div className="space-y-2">
<div className="flex justify-between items-center text-base-content/70">
<span>Subtotal:</span>
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-base-content/70">
<span>Tax:</span>
<span className="font-mono">${tax.toFixed(2)}</span>
</div>
<div className="divider my-1"></div>
<div className="flex justify-between items-center font-medium text-lg">
<span>Total:</span>
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
</div>
</div>
</motion.div>
{/* Action Buttons */}
<motion.div variants={itemVariants} className="flex justify-end gap-3 mt-8">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn btn-ghost hover:btn-error transition-all duration-300"
onClick={onCancel}
>
Cancel
</motion.button>
<motion.button
whileHover={{ scale: 1.02, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
type="submit"
className="btn btn-primary shadow-md hover:shadow-lg transition-all duration-300"
>
Add Receipt
</motion.button>
</motion.div>
</form>
</motion.div>
{/* Right side - Preview */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="border-l border-base-300 pl-6"
>
<AnimatePresence mode="wait">
{previewUrl ? (
<motion.div
key="preview"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
>
<FilePreview
url={previewUrl}
filename={file?.name || ''}
isModal={false}
/>
</motion.div>
) : (
<motion.div
key="placeholder"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-center justify-center h-full text-base-content/70"
>
<div className="text-center">
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto mb-4 text-base-content/30" />
<p className="text-lg">Upload a receipt to preview</p>
<p className="text-sm text-base-content/50 mt-2">Supported formats: Images, PDF</p>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
);
}

View file

@ -0,0 +1,652 @@
import React, { useState, useEffect, useRef } from 'react';
import { Icon } from '@iconify/react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import FilePreview from '../universal/FilePreview';
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
interface ReceiptFormData {
file: File;
itemized_expenses: ItemizedExpense[];
tax: number;
date: string;
location_name: string;
location_address: string;
notes: string;
}
// Extended Reimbursement interface with form-specific fields
interface ReimbursementRequest extends Partial<Omit<Reimbursement, 'receipts'>> {
title: string;
total_amount: number;
date_of_purchase: string;
payment_method: string;
status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress';
additional_info: string;
receipts: string[];
department: 'internal' | 'external' | 'projects' | 'events' | 'other';
}
const PAYMENT_METHODS = [
'Personal Credit Card',
'Personal Debit Card',
'Cash',
'Personal Check',
'Other'
];
const DEPARTMENTS = [
'internal',
'external',
'projects',
'events',
'other'
] as const;
const DEPARTMENT_LABELS = {
internal: 'Internal',
external: 'External',
projects: 'Projects',
events: 'Events',
other: 'Other'
};
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
export default function ReimbursementForm() {
const [request, setRequest] = useState<ReimbursementRequest>({
title: '',
total_amount: 0,
date_of_purchase: new Date().toISOString().split('T')[0],
payment_method: '',
status: 'submitted',
additional_info: '',
receipts: [],
department: 'internal'
});
const [receipts, setReceipts] = useState<(ReceiptFormData & { id: string })[]>([]);
const [showReceiptForm, setShowReceiptForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>('');
const [showReceiptDetails, setShowReceiptDetails] = useState(false);
const [selectedReceiptDetails, setSelectedReceiptDetails] = useState<ReceiptFormData | null>(null);
const [hasZelleInfo, setHasZelleInfo] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const auth = Authentication.getInstance();
useEffect(() => {
checkZelleInformation();
}, []);
const checkZelleInformation = async () => {
try {
setIsLoading(true);
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
throw new Error('User not authenticated');
}
const user = await pb.collection('users').getOne(userId);
setHasZelleInfo(!!user.zelle_information);
} catch (error) {
console.error('Error checking Zelle information:', error);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<div className="loading loading-spinner loading-lg text-primary"></div>
<p className="mt-4 text-base-content/70">Loading...</p>
</div>
);
}
if (hasZelleInfo === false) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-2xl mx-auto text-center py-12"
>
<div className="card bg-base-200 p-8">
<Icon icon="heroicons:exclamation-triangle" className="h-16 w-16 mx-auto text-warning" />
<h2 className="text-2xl font-bold mt-6">Zelle Information Required</h2>
<p className="mt-4 text-base-content/70">
Before submitting a reimbursement request, you need to provide your Zelle information.
This is required for processing your reimbursement payments.
</p>
<div className="mt-8">
<button
className="btn btn-primary gap-2"
onClick={() => {
const profileBtn = document.querySelector('[data-section="settings"]') as HTMLButtonElement;
if (profileBtn) profileBtn.click();
}}
>
<Icon icon="heroicons:user-circle" className="h-5 w-5" />
Update Profile
</button>
</div>
</div>
</motion.div>
);
}
const handleAddReceipt = async (receiptData: ReceiptFormData) => {
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
toast.error('User not authenticated');
throw new Error('User not authenticated');
}
// Create receipt record
const formData = new FormData();
formData.append('file', receiptData.file);
formData.append('created_by', userId);
formData.append('itemized_expenses', JSON.stringify(receiptData.itemized_expenses));
formData.append('tax', receiptData.tax.toString());
formData.append('date', new Date(receiptData.date).toISOString());
formData.append('location_name', receiptData.location_name);
formData.append('location_address', receiptData.location_address);
formData.append('notes', receiptData.notes);
const response = await pb.collection('receipts').create(formData);
// Sync the receipts collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.RECEIPTS);
// Add receipt to state
setReceipts(prev => [...prev, { ...receiptData, id: response.id }]);
// Update total amount
const totalAmount = receiptData.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receiptData.tax;
setRequest(prev => ({
...prev,
total_amount: prev.total_amount + totalAmount,
receipts: [...prev.receipts, response.id]
}));
setShowReceiptForm(false);
toast.success('Receipt added successfully');
} catch (error) {
console.error('Error creating receipt:', error);
toast.error('Failed to add receipt');
setError('Failed to add receipt. Please try again.');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (!request.title.trim()) {
toast.error('Title is required');
setError('Title is required');
return;
}
if (!request.payment_method) {
toast.error('Payment method is required');
setError('Payment method is required');
return;
}
if (receipts.length === 0) {
toast.error('At least one receipt is required');
setError('At least one receipt is required');
return;
}
setIsSubmitting(true);
setError('');
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
throw new Error('User not authenticated');
}
// Create reimbursement record
const formData = new FormData();
formData.append('title', request.title);
formData.append('total_amount', request.total_amount.toString());
formData.append('date_of_purchase', new Date(request.date_of_purchase).toISOString());
formData.append('payment_method', request.payment_method);
formData.append('status', 'submitted');
formData.append('submitted_by', userId);
formData.append('additional_info', request.additional_info);
formData.append('receipts', JSON.stringify(request.receipts));
formData.append('department', request.department);
await pb.collection('reimbursement').create(formData);
// Sync the reimbursements collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
// Reset form
setRequest({
title: '',
total_amount: 0,
date_of_purchase: new Date().toISOString().split('T')[0],
payment_method: '',
status: 'submitted',
additional_info: '',
receipts: [],
department: 'internal'
});
setReceipts([]);
setError('');
toast.success('🎉 Reimbursement request submitted successfully! Check "My Requests" to view it.', {
duration: 5000,
position: 'top-center',
style: {
background: '#10B981',
color: '#FFFFFF',
padding: '16px',
borderRadius: '8px',
}
});
} catch (error) {
console.error('Error submitting reimbursement request:', error);
toast.error('Failed to submit reimbursement request. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="max-w-4xl mx-auto"
>
<form onSubmit={handleSubmit} className="space-y-8">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Title */}
<div className="form-control md:col-span-2">
<label className="label">
<span className="label-text font-medium">Title</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={request.title}
onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))}
required
/>
</div>
{/* Date of Purchase */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Date of Purchase</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300"
value={request.date_of_purchase}
onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))}
required
/>
</div>
{/* Payment Method */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Payment Method</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
value={request.payment_method}
onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))}
required
>
<option value="">Select payment method</option>
{PAYMENT_METHODS.map(method => (
<option key={method} value={method}>{method}</option>
))}
</select>
</div>
{/* Department */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Department</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
value={request.department}
onChange={(e) => setRequest(prev => ({ ...prev, department: e.target.value as typeof DEPARTMENTS[number] }))}
required
>
{DEPARTMENTS.map(dept => (
<option key={dept} value={dept}>{DEPARTMENT_LABELS[dept]}</option>
))}
</select>
</div>
{/* Additional Info */}
<div className="form-control md:col-span-2">
<label className="label">
<span className="label-text font-medium">Additional Information</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
value={request.additional_info}
onChange={(e) => setRequest(prev => ({ ...prev, additional_info: e.target.value }))}
rows={3}
/>
</div>
</motion.div>
{/* Receipts */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipts</h3>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={() => setShowReceiptForm(true)}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Receipt
</motion.button>
</div>
{receipts.length > 0 ? (
<motion.div layout className="grid gap-4">
<AnimatePresence>
{receipts.map((receipt, index) => (
<motion.div
key={receipt.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 shadow-sm"
>
<div className="card-body p-4">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg">{receipt.location_name}</h3>
<p className="text-sm text-base-content/70">{receipt.location_address}</p>
<p className="text-sm mt-2">
Total: <span className="font-mono font-medium text-primary">${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}</span>
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-ghost btn-sm gap-2"
onClick={() => {
setSelectedReceiptDetails(receipt);
setShowReceiptDetails(true);
}}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</motion.button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12 bg-base-100 rounded-lg"
>
<Icon icon="heroicons:receipt" className="h-16 w-16 mx-auto text-base-content/30" />
<h3 className="mt-4 text-lg font-medium">No receipts added</h3>
<p className="text-base-content/70 mt-2">Add receipts to continue</p>
</motion.div>
)}
{receipts.length > 0 && (
<div className="mt-4 p-4 bg-base-100 rounded-lg">
<div className="flex justify-between items-center text-lg font-medium">
<span>Total Amount:</span>
<span className="font-mono text-primary">${request.total_amount.toFixed(2)}</span>
</div>
</div>
)}
</motion.div>
{/* Submit Button */}
<motion.div
variants={itemVariants}
className="mt-8"
>
<motion.button
whileHover={{ scale: 1.01, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.99 }}
type="submit"
className="btn btn-primary w-full h-12 shadow-md hover:shadow-lg transition-all duration-300 text-lg"
disabled={isSubmitting || receipts.length === 0}
>
{isSubmitting ? (
<span className="loading loading-spinner loading-md"></span>
) : (
<>
<Icon icon="heroicons:paper-airplane" className="h-5 w-5" />
Submit Reimbursement Request
</>
)}
</motion.button>
</motion.div>
</form>
{/* Receipt Form Modal */}
<AnimatePresence>
{showReceiptForm && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-5xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Add Receipt</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => setShowReceiptForm(false)}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<ReceiptForm
onSubmit={handleAddReceipt}
onCancel={() => setShowReceiptForm(false)}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Receipt Details Modal */}
<AnimatePresence>
{showReceiptDetails && selectedReceiptDetails && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-4xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Receipt Details
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => {
setShowReceiptDetails(false);
setSelectedReceiptDetails(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Location</label>
<p className="mt-1">{selectedReceiptDetails.location_name}</p>
<p className="text-sm text-base-content/70">{selectedReceiptDetails.location_address}</p>
</div>
<div>
<label className="text-sm font-medium">Date</label>
<p className="mt-1">{new Date(selectedReceiptDetails.date).toLocaleDateString()}</p>
</div>
{selectedReceiptDetails.notes && (
<div>
<label className="text-sm font-medium">Notes</label>
<p className="mt-1">{selectedReceiptDetails.notes}</p>
</div>
)}
<div>
<label className="text-sm font-medium">Itemized Expenses</label>
<div className="mt-2 space-y-2">
{selectedReceiptDetails.itemized_expenses.map((item, index) => (
<div key={index} className="card bg-base-200 p-3">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs font-medium">Description</label>
<p>{item.description}</p>
</div>
<div>
<label className="text-xs font-medium">Category</label>
<p>{item.category}</p>
</div>
<div>
<label className="text-xs font-medium">Amount</label>
<p>${item.amount.toFixed(2)}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Tax</label>
<p className="mt-1">${selectedReceiptDetails.tax.toFixed(2)}</p>
</div>
<div>
<label className="text-sm font-medium">Total</label>
<p className="mt-1">${(selectedReceiptDetails.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceiptDetails.tax).toFixed(2)}</p>
</div>
</div>
</div>
<div className="border-l border-base-300 pl-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipt Image</h3>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
<FilePreview
url={URL.createObjectURL(selectedReceiptDetails.file)}
filename={selectedReceiptDetails.file.name}
isModal={false}
/>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
);
}

View file

@ -0,0 +1,848 @@
import React, { useState, useEffect } from 'react';
import { Icon } from '@iconify/react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import FilePreview from '../universal/FilePreview';
import { motion, AnimatePresence } from 'framer-motion';
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
interface AuditNote {
note: string;
auditor_id: string;
timestamp: string;
is_private: boolean;
}
// Extended Reimbursement interface with component-specific properties
interface ReimbursementRequest extends Omit<Reimbursement, 'audit_notes'> {
audit_notes: AuditNote[] | null;
}
// Extended Receipt interface with component-specific properties
interface ReceiptDetails extends Omit<Receipt, 'itemized_expenses' | 'audited_by'> {
file: string;
itemized_expenses: ItemizedExpense[];
audited_by: string[];
created: string;
updated: string;
}
const STATUS_COLORS = {
submitted: 'badge-primary',
under_review: 'badge-warning',
approved: 'badge-success',
rejected: 'badge-error',
paid: 'badge-success',
in_progress: 'badge-info'
};
const STATUS_LABELS = {
submitted: 'Submitted',
under_review: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
paid: 'Paid',
in_progress: 'In Progress'
};
const DEPARTMENT_LABELS = {
internal: 'Internal',
external: 'External',
projects: 'Projects',
events: 'Events',
other: 'Other'
};
// Add this after the STATUS_LABELS constant
const STATUS_ORDER = ['submitted', 'under_review', 'approved', 'rejected', 'in_progress', 'paid'] as const;
const STATUS_ICONS = {
submitted: 'heroicons:paper-airplane',
under_review: 'heroicons:eye',
approved: 'heroicons:check-circle',
rejected: 'heroicons:x-circle',
in_progress: 'heroicons:clock',
paid: 'heroicons:banknotes'
} as const;
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.3,
when: "beforeChildren",
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
type: "spring",
stiffness: 100,
damping: 15
}
}
};
export default function ReimbursementList() {
const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewFilename, setPreviewFilename] = useState('');
const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null);
const [receiptDetailsMap, setReceiptDetailsMap] = useState<Record<string, ReceiptDetails>>({});
const get = Get.getInstance();
const auth = Authentication.getInstance();
const fileManager = FileManager.getInstance();
useEffect(() => {
console.log('Component mounted');
fetchReimbursements();
}, []);
// Add effect to monitor requests state
useEffect(() => {
console.log('Requests state updated:', requests);
console.log('Number of requests:', requests.length);
}, [requests]);
// Add a useEffect to log preview URL and filename changes
useEffect(() => {
console.log('Preview URL changed:', previewUrl);
console.log('Preview filename changed:', previewFilename);
}, [previewUrl, previewFilename]);
// Add a useEffect to log when the preview modal is shown/hidden
useEffect(() => {
console.log('Show preview changed:', showPreview);
if (showPreview) {
console.log('Selected receipt:', selectedReceipt);
}
}, [showPreview, selectedReceipt]);
const fetchReimbursements = async () => {
setLoading(true);
setError('');
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
throw new Error('User not authenticated');
}
// Use DataSyncService to get data from IndexedDB with forced sync
const dataSync = DataSyncService.getInstance();
// Sync reimbursements collection
await dataSync.syncCollection(
Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`,
'-created',
'audit_notes'
);
// Get reimbursements from IndexedDB
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
Collections.REIMBURSEMENTS,
false, // Don't force sync again
`submitted_by="${userId}"`,
'-created'
);
console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
// Process the records
const processedRecords = reimbursementRecords.map(record => {
// Process audit notes if they exist
let auditNotes = null;
if (record.audit_notes) {
try {
// If it's a string, parse it
if (typeof record.audit_notes === 'string') {
auditNotes = JSON.parse(record.audit_notes);
} else {
// Otherwise use it directly
auditNotes = record.audit_notes;
}
} catch (e) {
console.error('Error parsing audit notes:', e);
}
}
return {
...record,
audit_notes: auditNotes
};
});
setRequests(processedRecords);
// Fetch receipt details for each reimbursement
for (const record of processedRecords) {
if (record.receipts && record.receipts.length > 0) {
for (const receiptId of record.receipts) {
try {
// Get receipt from IndexedDB
const receiptRecord = await dataSync.getItem<ReceiptDetails>(
Collections.RECEIPTS,
receiptId
);
if (receiptRecord) {
// Process itemized expenses
let itemizedExpenses: ItemizedExpense[] = [];
if (receiptRecord.itemized_expenses) {
try {
if (typeof receiptRecord.itemized_expenses === 'string') {
itemizedExpenses = JSON.parse(receiptRecord.itemized_expenses);
} else {
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
}
} catch (e) {
console.error('Error parsing itemized expenses:', e);
}
}
// Add receipt to state
setReceiptDetailsMap(prevMap => ({
...prevMap,
[receiptId]: {
id: receiptRecord.id,
file: receiptRecord.file,
created_by: receiptRecord.created_by,
date: receiptRecord.date,
location_name: receiptRecord.location_name,
location_address: receiptRecord.location_address,
notes: receiptRecord.notes,
tax: receiptRecord.tax,
created: receiptRecord.created,
updated: receiptRecord.updated,
itemized_expenses: itemizedExpenses,
audited_by: receiptRecord.audited_by || []
}
}));
}
} catch (e) {
console.error(`Error fetching receipt ${receiptId}:`, e);
}
}
}
}
} catch (err) {
console.error('Error fetching reimbursements:', err);
setError('Failed to load reimbursements. Please try again.');
} finally {
setLoading(false);
}
};
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
try {
console.log('Previewing file for receipt ID:', receiptId);
const pb = auth.getPocketBase();
const fileManager = FileManager.getInstance();
// Set the selected request
setSelectedRequest(request);
// Check if we already have the receipt details in our map
if (receiptDetailsMap[receiptId]) {
console.log('Using cached receipt details');
// Use the cached receipt details
setSelectedReceipt(receiptDetailsMap[receiptId]);
// Check if the receipt has a file
if (!receiptDetailsMap[receiptId].file) {
console.error('Receipt has no file attached');
toast.error('This receipt has no file attached');
setPreviewUrl('');
setPreviewFilename('');
setShowPreview(true);
return;
}
// Get the file URL with token for protected files
console.log('Getting file URL with token');
const url = await fileManager.getFileUrlWithToken(
'receipts',
receiptId,
receiptDetailsMap[receiptId].file,
true // Use token for protected files
);
// Check if the URL is empty
if (!url) {
console.error('Failed to get file URL: Empty URL returned');
toast.error('Failed to load receipt: Could not generate file URL');
// Still show the preview modal but with empty URL to display the error message
setPreviewUrl('');
setPreviewFilename(receiptDetailsMap[receiptId].file || '');
setShowPreview(true);
return;
}
console.log('Got URL:', url.substring(0, 50) + '...');
// Set the preview URL and filename
setPreviewUrl(url);
setPreviewFilename(receiptDetailsMap[receiptId].file);
// Show the preview modal
setShowPreview(true);
// Log the current state
console.log('Current state after setting:', {
previewUrl: url,
previewFilename: receiptDetailsMap[receiptId].file,
showPreview: true
});
return;
}
// If not in the map, get the receipt record using its ID
console.log('Fetching receipt details from server');
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
$autoCancel: false
});
if (receiptRecord) {
console.log('Receipt record found:', receiptRecord.id);
console.log('Receipt file:', receiptRecord.file);
// Check if the receipt has a file
if (!receiptRecord.file) {
console.error('Receipt has no file attached');
toast.error('This receipt has no file attached');
setPreviewUrl('');
setPreviewFilename('');
setShowPreview(true);
return;
}
// Parse the itemized expenses if it's a string
const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string'
? JSON.parse(receiptRecord.itemized_expenses)
: receiptRecord.itemized_expenses;
const receiptDetails: ReceiptDetails = {
id: receiptRecord.id,
file: receiptRecord.file,
created_by: receiptRecord.created_by,
itemized_expenses: itemizedExpenses,
tax: receiptRecord.tax,
date: receiptRecord.date,
location_name: receiptRecord.location_name,
location_address: receiptRecord.location_address,
notes: receiptRecord.notes || '',
audited_by: receiptRecord.audited_by || [],
created: receiptRecord.created,
updated: receiptRecord.updated
};
// Add to the map for future use
setReceiptDetailsMap(prevMap => ({
...prevMap,
[receiptId]: receiptDetails
}));
setSelectedReceipt(receiptDetails);
// Get the file URL with token for protected files
console.log('Getting file URL with token for new receipt');
const url = await fileManager.getFileUrlWithToken(
'receipts',
receiptRecord.id,
receiptRecord.file,
true // Use token for protected files
);
// Check if the URL is empty
if (!url) {
console.error('Failed to get file URL: Empty URL returned');
toast.error('Failed to load receipt: Could not generate file URL');
// Still show the preview modal but with empty URL to display the error message
setPreviewUrl('');
setPreviewFilename(receiptRecord.file || '');
setShowPreview(true);
return;
}
console.log('Got URL:', url.substring(0, 50) + '...');
// Set the preview URL and filename
setPreviewUrl(url);
setPreviewFilename(receiptRecord.file);
// Show the preview modal
setShowPreview(true);
// Log the current state
console.log('Current state after setting:', {
previewUrl: url,
previewFilename: receiptRecord.file,
showPreview: true
});
} else {
throw new Error('Receipt not found');
}
} catch (error) {
console.error('Error loading receipt:', error);
toast.error('Failed to load receipt. Please try again.');
// Show the preview modal with empty URL to display the error message
setPreviewUrl('');
setPreviewFilename('');
setShowPreview(true);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
if (loading) {
console.log('Rendering loading state');
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center min-h-[400px] p-8"
>
<div className="loading loading-spinner loading-lg text-primary mb-4"></div>
<p className="text-base-content/70 animate-pulse">Loading your reimbursements...</p>
</motion.div>
);
}
if (error) {
console.log('Rendering error state:', error);
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="alert alert-error shadow-lg max-w-2xl mx-auto"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
);
}
console.log('Rendering main component. Requests:', requests);
console.log('Requests length:', requests.length);
return (
<>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6"
>
{requests.length === 0 ? (
<motion.div
variants={itemVariants}
initial="hidden"
animate="visible"
className="text-center py-16 bg-base-200/50 backdrop-blur-sm rounded-2xl border-2 border-dashed border-base-300"
>
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto text-base-content/30" />
<h3 className="mt-6 text-xl font-medium">No reimbursement requests</h3>
<p className="text-base-content/70 mt-2">Create a new request to get started</p>
</motion.div>
) : (
<motion.div
layout
initial="hidden"
animate="visible"
variants={containerVariants}
className="grid gap-4"
>
<AnimatePresence mode="popLayout">
{requests.map((request, index) => {
console.log('Rendering request:', request);
return (
<motion.div
key={request.id}
variants={itemVariants}
initial="hidden"
animate="visible"
layout
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 border border-base-200 hover:border-primary shadow-sm hover:shadow-md"
>
<div className="card-body p-5">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex-1 min-w-0">
<h3 className="card-title text-lg font-bold truncate">{request.title}</h3>
<div className="flex flex-wrap gap-2 mt-2">
<div className="badge badge-outline badge-lg font-mono">
${request.total_amount.toFixed(2)}
</div>
<div className="badge badge-ghost badge-lg gap-1">
<Icon icon="heroicons:calendar" className="h-4 w-4" />
{formatDate(request.date_of_purchase)}
</div>
{request.audit_notes && request.audit_notes.filter(note => !note.is_private).length > 0 && (
<div className="badge badge-ghost badge-lg gap-1">
<Icon icon="heroicons:chat-bubble-left-right" className="h-4 w-4" />
{request.audit_notes.filter(note => !note.is_private).length} Notes
</div>
)}
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="btn btn-primary btn-sm gap-2 shadow-sm hover:shadow-md transition-all duration-300"
onClick={() => setSelectedRequest(request)}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</motion.button>
</div>
<div className="mt-4 card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<div className="flex items-center justify-between w-full relative py-2">
<div className="absolute left-0 right-0 top-1/2 h-0.5 bg-base-300 -translate-y-[1.0rem]" />
{STATUS_ORDER.map((status, index) => {
if (status === 'rejected' && request.status !== 'rejected') return null;
if (status === 'approved' && request.status === 'rejected') return null;
const isActive = STATUS_ORDER.indexOf(request.status) >= STATUS_ORDER.indexOf(status);
const isCurrent = request.status === status;
return (
<div key={status} className="relative flex flex-col items-center gap-2 z-10">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${isCurrent
? status === 'rejected'
? 'bg-error text-error-content ring-2 ring-error/20'
: status === 'paid'
? 'bg-success text-success-content ring-2 ring-success/20'
: status === 'in_progress'
? 'bg-warning text-warning-content ring-2 ring-warning/20'
: 'bg-primary text-primary-content ring-2 ring-primary/20'
: isActive
? status === 'rejected'
? 'bg-error/20 text-error'
: status === 'paid'
? 'bg-success/20 text-success'
: 'bg-primary/20 text-primary'
: 'bg-base-300 text-base-content/40'
}`}>
<Icon icon={STATUS_ICONS[status]} className="h-3.5 w-3.5" />
</div>
<span className={`text-[10px] font-medium whitespace-nowrap mt-1 ${isCurrent
? status === 'rejected'
? 'text-error'
: status === 'paid'
? 'text-success'
: status === 'in_progress'
? 'text-warning'
: 'text-primary'
: isActive
? 'text-base-content'
: 'text-base-content/40'
}`}>
{STATUS_LABELS[status]}
</span>
</div>
);
})}
</div>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Details Modal */}
<AnimatePresence>
{selectedRequest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-3xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
{selectedRequest.title}
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => setSelectedRequest(null)}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Status</label>
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} badge-lg gap-1 mt-1`}>
<Icon icon={STATUS_ICONS[selectedRequest.status]} className="h-4 w-4" />
{STATUS_LABELS[selectedRequest.status]}
</div>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Department</label>
<div className="badge badge-outline badge-lg mt-1">
{DEPARTMENT_LABELS[selectedRequest.department]}
</div>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Total Amount</label>
<p className="mt-1 text-xl font-mono font-bold text-primary">
${selectedRequest.total_amount.toFixed(2)}
</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Date of Purchase</label>
<p className="mt-1 font-medium">{formatDate(selectedRequest.date_of_purchase)}</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm col-span-2">
<label className="text-sm font-medium text-base-content/70">Payment Method</label>
<p className="mt-1 font-medium">{selectedRequest.payment_method}</p>
</div>
</div>
{selectedRequest.additional_info && (
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Additional Information</label>
<p className="mt-2 whitespace-pre-wrap">{selectedRequest.additional_info}</p>
</div>
)}
{selectedRequest.audit_notes && selectedRequest.audit_notes.filter(note => !note.is_private).length > 0 && (
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm border-l-4 border-primary">
<div className="flex items-center gap-2 mb-3">
<Icon icon="heroicons:chat-bubble-left-right" className="h-5 w-5 text-primary" />
<label className="text-base font-medium">Public Notes</label>
</div>
<div className="space-y-3">
{selectedRequest.audit_notes
.filter(note => !note.is_private)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.map((note, index) => (
<div key={index} className="card bg-base-100 p-4 hover:bg-base-200 transition-colors duration-200">
<p className="whitespace-pre-wrap text-base">{note.note}</p>
<div className="flex justify-between items-center mt-3 text-sm text-base-content/70">
<span className="flex items-center gap-1">
<Icon icon="heroicons:clock" className="h-4 w-4" />
{formatDate(note.timestamp)}
</span>
</div>
</div>
))}
</div>
</div>
)}
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70 mb-2">Receipts</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{(selectedRequest.receipts || []).map((receiptId, index) => (
<motion.button
key={receiptId || index}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="btn btn-outline btn-sm normal-case gap-2 hover:shadow-md transition-all duration-300"
onClick={() => handlePreviewFile(selectedRequest, receiptId)}
>
<Icon icon="heroicons:document" className="h-4 w-4" />
Receipt #{index + 1}
</motion.button>
))}
</div>
</div>
<div className="divider before:bg-base-300 after:bg-base-300"></div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Submitted At</label>
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Last Updated</label>
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* File Preview Modal */}
<AnimatePresence>
{showPreview && selectedReceipt && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-7xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Receipt Details
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => {
setShowPreview(false);
setSelectedReceipt(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid grid-cols-5 gap-6">
{/* Receipt Details */}
<div className="col-span-2 space-y-4">
<div>
<label className="text-sm font-medium">Location</label>
<p className="mt-1">{selectedReceipt.location_name}</p>
<p className="text-sm text-base-content/70">{selectedReceipt.location_address}</p>
</div>
<div>
<label className="text-sm font-medium">Date</label>
<p className="mt-1">{formatDate(selectedReceipt.date)}</p>
</div>
{selectedReceipt.notes && (
<div>
<label className="text-sm font-medium">Notes</label>
<p className="mt-1">{selectedReceipt.notes}</p>
</div>
)}
<div>
<label className="text-sm font-medium">Itemized Expenses</label>
<div className="mt-2 space-y-2">
{selectedReceipt.itemized_expenses.map((item, index) => (
<div key={index} className="card bg-base-200 p-3">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs font-medium">Description</label>
<p>{item.description}</p>
</div>
<div>
<label className="text-xs font-medium">Category</label>
<p>{item.category}</p>
</div>
<div>
<label className="text-xs font-medium">Amount</label>
<p>${item.amount.toFixed(2)}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Tax</label>
<p className="mt-1">${selectedReceipt.tax.toFixed(2)}</p>
</div>
<div>
<label className="text-sm font-medium">Total</label>
<p className="mt-1">${(selectedReceipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceipt.tax).toFixed(2)}</p>
</div>
</div>
</div>
{/* File Preview */}
<div className="col-span-3 border-l border-base-300 pl-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipt Image</h3>
<motion.a
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-outline gap-2 hover:shadow-md transition-all duration-300"
>
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
View Full Size
</motion.a>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
{previewUrl ? (
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
) : (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Receipt Image Not Available</h3>
<p className="text-base-content/70 max-w-md">
The receipt image could not be loaded. This might be due to permission issues or the file may not exist.
</p>
</div>
</div>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,596 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Icon } from "@iconify/react";
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Image component with fallback handling
interface ImageWithFallbackProps {
url: string;
filename: string;
onError: (message: string) => void;
}
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
const [imgSrc, setImgSrc] = useState<string>(url);
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
// Clean up object URL when component unmounts
useEffect(() => {
return () => {
if (isObjectUrl && imgSrc !== url) {
URL.revokeObjectURL(imgSrc);
}
};
}, [imgSrc, url, isObjectUrl]);
const handleError = async () => {
console.error('Image failed to load:', url);
try {
// Try to fetch the image as a blob and create an object URL
console.log('Trying to fetch image as blob:', url);
const response = await fetch(url, { mode: 'cors' });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
console.log('Created object URL:', objectUrl);
// Update the image source with the object URL
setImgSrc(objectUrl);
setIsObjectUrl(true);
} catch (fetchError) {
console.error('Error fetching image as blob:', fetchError);
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
// Log additional details
console.log('Image URL that failed:', url);
console.log('Current auth status:',
Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated'
);
}
};
return (
<img
src={imgSrc}
alt={filename}
className="max-w-full h-auto rounded-lg"
loading="lazy"
onError={handleError}
/>
);
};
// Cache for file content
const contentCache = new Map<string, { content: string | 'image' | 'video' | 'pdf', fileType: string, timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface FilePreviewProps {
url?: string;
filename?: string;
isModal?: boolean;
}
export default function FilePreview({ url: initialUrl = '', filename: initialFilename = '', isModal = false }: FilePreviewProps) {
// Constants
const CHUNK_SIZE = 50;
const INITIAL_LINES_TO_SHOW = 20;
// Consolidate state management with useRef for latest values
const latestPropsRef = useRef({ url: initialUrl, filename: initialFilename });
const loadingRef = useRef(false); // Add a ref to track loading state
const [state, setState] = useState({
url: initialUrl,
filename: initialFilename,
content: null as string | 'image' | 'video' | 'pdf' | null,
error: null as string | null,
loading: false,
fileType: null as string | null,
isVisible: false,
visibleLines: INITIAL_LINES_TO_SHOW
});
// Memoize the truncated filename
const truncatedFilename = useMemo(() => {
if (!state.filename) return '';
const maxLength = 40;
if (state.filename.length <= maxLength) return state.filename;
const extension = state.filename.split('.').pop();
const name = state.filename.substring(0, state.filename.lastIndexOf('.'));
const truncatedName = name.substring(0, maxLength - 3 - (extension?.length || 0));
return `${truncatedName}...${extension ? `.${extension}` : ''}`;
}, [state.filename]);
// Update ref when props change
useEffect(() => {
latestPropsRef.current = { url: initialUrl, filename: initialFilename };
loadingRef.current = false; // Reset loading ref
// Clear state when URL changes
setState(prev => ({
...prev,
url: initialUrl,
filename: initialFilename,
content: null,
error: null,
fileType: null,
loading: false
}));
}, [initialUrl, initialFilename]);
// Single effect for modal event handling
useEffect(() => {
if (isModal) {
const handleStateChange = (event: CustomEvent<{ url: string; filename: string }>) => {
const { url: newUrl, filename: newFilename } = event.detail;
// Force clear cache for PDFs to prevent stale content
if (newUrl.endsWith('.pdf')) {
contentCache.delete(`${newUrl}_${newFilename}`);
}
setState(prev => ({
...prev,
url: newUrl,
filename: newFilename,
content: null,
error: null,
fileType: null,
loading: true
}));
};
window.addEventListener('filePreviewStateChange', handleStateChange as EventListener);
return () => {
window.removeEventListener('filePreviewStateChange', handleStateChange as EventListener);
};
}
}, [isModal]);
// Consolidated content loading effect
const loadContent = useCallback(async () => {
if (!state.url) {
setState(prev => ({ ...prev, error: 'No file URL provided', loading: false }));
return;
}
// Prevent duplicate loading
if (loadingRef.current) {
return;
}
loadingRef.current = true;
setState(prev => ({ ...prev, loading: true, error: null }));
try {
// Special handling for PDFs
if (state.url.endsWith('.pdf')) {
setState(prev => ({
...prev,
content: 'pdf',
fileType: 'application/pdf',
loading: false
}));
loadingRef.current = false;
return;
}
// Rest of your existing loadContent logic
// ... existing content loading code ...
} catch (err) {
console.error('Error loading content:', err);
setState(prev => ({
...prev,
error: err instanceof Error ? err.message : 'Failed to load file',
loading: false
}));
loadingRef.current = false;
}
}, [state.url]);
useEffect(() => {
if (!state.url || (!state.isVisible && isModal)) return;
loadContent();
}, [state.url, state.isVisible, isModal, loadContent]);
// Intersection observer effect
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setState(prev => ({ ...prev, isVisible: entry.isIntersecting }));
},
{ threshold: 0.1 }
);
const previewElement = document.querySelector('.file-preview-container');
if (previewElement) {
observer.observe(previewElement);
}
return () => observer.disconnect();
}, []);
const handleDownload = async () => {
try {
const response = await fetch(state.url);
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = state.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (err) {
console.error('Error downloading file:', err);
alert('Failed to download file. Please try again.');
}
};
const getLanguageFromFilename = (filename: string): string => {
const extension = filename.split('.').pop()?.toLowerCase();
switch (extension) {
case 'js':
case 'jsx':
return 'javascript';
case 'ts':
case 'tsx':
return 'typescript';
case 'py':
return 'python';
case 'html':
return 'html';
case 'css':
return 'css';
case 'json':
return 'json';
case 'md':
return 'markdown';
case 'yml':
case 'yaml':
return 'yaml';
case 'csv':
return 'csv';
case 'txt':
return 'plaintext';
default:
// If no extension or unrecognized extension, default to plaintext
return 'plaintext';
}
};
const parseCSV = useCallback((csvContent: string) => {
const lines = csvContent.split('\n').map(line =>
line.split(',').map(cell =>
cell.trim().replace(/^["'](.*)["']$/, '$1')
)
);
const headers = lines[0];
const dataRows = lines.slice(1).filter(row => row.some(cell => cell.length > 0)); // Skip empty rows
// Remove the truncation message if it exists
const lastRow = dataRows[dataRows.length - 1];
if (lastRow && lastRow[0] && lastRow[0].includes('Content truncated')) {
dataRows.pop();
}
return { headers, rows: dataRows };
}, []);
const renderCSVTable = useCallback((csvContent: string) => {
const { headers, rows } = parseCSV(csvContent);
const totalRows = rows.length;
const rowsToShow = Math.min(state.visibleLines, totalRows);
const displayedRows = rows.slice(0, rowsToShow);
return `
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr class="bg-base-200">
${headers.map(header => `<th class="px-4 py-2 text-left font-medium">${header}</th>`).join('')}
</tr>
</thead>
<tbody>
${displayedRows.map((row, rowIndex) => `
<tr class="${rowIndex % 2 === 0 ? 'bg-base-100' : 'bg-base-200/50'}">
${row.map(cell => `<td class="px-4 py-2 border-t border-base-300">${cell}</td>`).join('')}
</tr>
`).join('')}
${rowsToShow < totalRows ? `
<tr>
<td colspan="${headers.length}" class="px-4 py-3 text-base-content/70 bg-base-200/30 border-t border-base-300">
... ${totalRows - rowsToShow} more rows
</td>
</tr>
` : ''}
</tbody>
</table>
</div>
`;
}, [state.visibleLines]);
const formatCodeWithLineNumbers = useCallback((code: string, language: string) => {
try {
// Use highlight.js to highlight the code
const highlighted = hljs.highlight(code, { language }).value;
const lines = highlighted.split('\n');
return lines.map((line, i) =>
`<div class="code-line">
<span class="line-number">${i + 1}</span>
<span class="line-content">${line || ' '}</span>
</div>`
).join('');
} catch (error) {
console.warn(`Failed to highlight code as ${language}, falling back to plaintext`);
const plaintext = hljs.highlight(code, { language: 'plaintext' }).value;
const lines = plaintext.split('\n');
return lines.map((line, i) =>
`<div class="code-line">
<span class="line-number">${i + 1}</span>
<span class="line-content">${line || ' '}</span>
</div>`
).join('');
}
}, []);
const highlightCode = useCallback((code: string, language: string) => {
// Skip highlighting for CSV
if (language === 'csv') {
return code;
}
return code; // Just return the code, formatting is handled in formatCodeWithLineNumbers
}, []);
const handleShowMore = useCallback(() => {
setState(prev => ({
...prev,
visibleLines: Math.min(prev.visibleLines + CHUNK_SIZE, (prev.content as string).split('\n').length)
}));
}, []);
const handleShowLess = useCallback(() => {
setState(prev => ({ ...prev, visibleLines: INITIAL_LINES_TO_SHOW }));
}, []);
// Update the Try Again button handler
const handleTryAgain = useCallback(() => {
loadingRef.current = false; // Reset loading ref
loadContent();
}, [loadContent]);
// If URL is empty, show a message
if (!state.url) {
return (
<div className="file-preview-container bg-base-100 rounded-lg shadow-md overflow-hidden">
<div className="p-6 flex flex-col items-center justify-center text-center">
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning mb-3" />
<h3 className="text-lg font-semibold mb-2">No File URL Provided</h3>
<p className="text-base-content/70">Please check if the file exists or if you have the necessary permissions.</p>
</div>
</div>
);
}
return (
<div className="file-preview-container space-y-4">
<div className="flex justify-between items-center bg-base-200 p-3 rounded-t-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate font-medium" title={state.filename}>{truncatedFilename}</span>
{state.fileType && (
<span className="badge badge-sm whitespace-nowrap">{state.fileType.split('/')[1]}</span>
)}
</div>
<button
onClick={handleDownload}
className="btn btn-sm btn-ghost gap-2 whitespace-nowrap"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download
</button>
</div>
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
{!state.loading && !state.error && state.content === null && (
<div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
)}
{state.loading && (
<div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
)}
{state.error && (
<div className="flex flex-col items-center justify-center p-8 bg-base-200 rounded-lg text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<Icon icon="mdi:alert" className="h-12 w-12 text-warning" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Preview Unavailable</h3>
<p className="text-base-content/70 max-w-md">{state.error}</p>
</div>
<button
onClick={handleDownload}
className="btn btn-warning btn-sm gap-2 mt-4"
>
<Icon icon="mdi:download" className="h-4 w-4" />
Download File Instead
</button>
<button
className="btn btn-sm btn-outline btn-error mt-4"
onClick={handleTryAgain}
>
Try Again
</button>
</div>
)}
{!state.loading && !state.error && state.content === 'image' && (
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<ImageWithFallback
url={state.url}
filename={state.filename}
onError={(message) => setState(prev => ({ ...prev, error: message }))}
/>
</div>
)}
{!state.loading && !state.error && state.content === 'video' && (
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
<video
controls
className="max-w-full h-auto rounded-lg"
preload="metadata"
onError={(e) => {
console.error('Video failed to load:', e);
setState(prev => ({
...prev,
error: 'Failed to load video. This might be due to permission issues or the file may not exist.'
}));
}}
>
<source src={state.url} type={state.fileType || 'video/mp4'} />
Your browser does not support the video tag.
</video>
</div>
)}
{!state.loading && !state.error && state.content === 'pdf' && (
<div className="w-full h-[600px] bg-base-200 p-4 rounded-b-lg">
<div className="w-full h-full rounded-lg overflow-hidden">
{/* Use object tag instead of iframe for better PDF support */}
<object
data={state.url}
type="application/pdf"
className="w-full h-full rounded-lg"
onError={(e) => {
console.error('PDF object failed to load:', e);
// Create a fallback div with a download link
const obj = e.target as HTMLObjectElement;
const container = obj.parentElement;
if (container) {
container.innerHTML = `
<div class="flex flex-col items-center justify-center h-full bg-base-200 p-6 text-center">
<div class="bg-warning/20 p-4 rounded-full mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">PDF Preview Unavailable</h3>
<p class="text-base-content/70 mb-4">The PDF cannot be displayed in the browser due to security restrictions.</p>
<a href="${state.url}" download="${state.filename}" class="btn btn-primary gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download PDF Instead
</a>
</div>
`;
}
}}
>
<div className="flex flex-col items-center justify-center h-full bg-base-200 p-6 text-center">
<div className="bg-warning/20 p-4 rounded-full mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">PDF Preview Unavailable</h3>
<p className="text-base-content/70 mb-4">Your browser cannot display this PDF or it failed to load.</p>
<a href={state.url} download={state.filename} className="btn btn-primary gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download PDF Instead
</a>
</div>
</object>
</div>
</div>
)}
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
<div className="overflow-x-auto max-h-[600px] bg-base-200">
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
{state.filename.toLowerCase().endsWith('.csv') ? (
<div dangerouslySetInnerHTML={{ __html: renderCSVTable(state.content) }} />
) : (
<>
<div className="file-preview-code-container text-sm">
<style>
{`
.file-preview-code-container {
font-family: monospace;
}
.file-preview-code-container .code-line {
display: flex;
white-space: pre;
}
.file-preview-code-container .line-number {
user-select: none;
text-align: right;
color: rgba(115, 115, 115, 0.6);
min-width: 40px;
padding-right: 12px;
display: inline-block;
}
.file-preview-code-container .line-content {
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
`}
</style>
<div
dangerouslySetInnerHTML={{
__html: formatCodeWithLineNumbers(
state.content.split('\n').slice(0, state.visibleLines).join('\n'),
getLanguageFromFilename(state.filename)
)
}}
/>
</div>
{state.content.split('\n').length > state.visibleLines && (
<div className="flex justify-center p-2 border-t border-base-300 bg-base-200/50">
{state.visibleLines < state.content.split('\n').length && (
<button
className="btn btn-sm btn-ghost gap-1"
onClick={handleShowMore}
>
<Icon icon="mdi:chevron-down" className="h-4 w-4" />
Show {Math.min(CHUNK_SIZE, state.content.split('\n').length - state.visibleLines)} more lines
</button>
)}
{state.visibleLines > INITIAL_LINES_TO_SHOW && (
<button
className="btn btn-sm btn-ghost gap-1 ml-2"
onClick={handleShowLess}
>
<Icon icon="mdi:chevron-up" className="h-4 w-4" />
Show less
</button>
)}
</div>
)}
</>
)}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { Toaster } from 'react-hot-toast';
// Centralized toast provider to ensure consistent rendering
export default function ToastProvider() {
return (
<Toaster
position="top-center"
toastOptions={{
duration: 4000,
style: {
background: '#333',
color: '#fff',
borderRadius: '8px',
padding: '12px',
},
success: {
style: {
background: 'green',
},
},
error: {
style: {
background: 'red',
},
duration: 2000,
},
}}
/>
);
}

View file

@ -5,11 +5,19 @@ import { LiaDotCircle } from "react-icons/lia";
--- ---
<div class="w-full md:pt-[5vw] pt-[10vw] flex justify-center relative"> <div class="w-full md:pt-[5vw] pt-[10vw] flex justify-center relative">
<Image <div class="w-[45%] rounded-[2vw] aspect-[2/1] relative">
src={eventbg} <div
alt="Event Page Background" id="event-skeleton"
class="md:w-[45%] w-[80%] rounded-[2vw] aspect-[2/1] object-cover" class="skeleton absolute inset-0 rounded-[2vw] z-0"
/> >
</div>
<Image
id="event-image"
src={eventbg}
alt="Event Page Background"
class="w-full h-full rounded-[2vw] object-cover absolute top-0 left-0 z-1"
/>
</div>
<div <div
class="absolute -bottom-[6%] md:left-[20%] left-[10%] flex items-center md:text-[3vw] text-[6vw] py-[1.5%] px-[3%] text-white bg-ieee-black rounded-[2vw]" class="absolute -bottom-[6%] md:left-[20%] left-[10%] flex items-center md:text-[3vw] text-[6vw] py-[1.5%] px-[3%] text-white bg-ieee-black rounded-[2vw]"
> >
@ -17,3 +25,16 @@ import { LiaDotCircle } from "react-icons/lia";
<p>EVENTS</p> <p>EVENTS</p>
</div> </div>
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const image = document.getElementById("event-image");
const skeleton = document.getElementById("event-skeleton");
if (image && skeleton) {
image.onload = () => {
skeleton.style.display = "none";
};
}
});
</script>

View file

@ -6,7 +6,9 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
--- ---
<div class="md:flex md:gap-[1.5vw] md:h-[30vw] md:w-auto w-[70vw] mt-[20%] md:mt-0 ml-[5%] md:ml-0"> <div
class="md:flex md:gap-[1.5vw] md:h-[30vw] md:w-auto w-[70vw] mt-[20%] md:mt-0 ml-[5%] md:ml-0"
>
{ {
Object.entries(annualProjects).map(([title, project], index) => ( Object.entries(annualProjects).map(([title, project], index) => (
<a <a
@ -15,6 +17,7 @@ import { Image } from "astro:assets";
data-project={index + 1} data-project={index + 1}
target={title === "Supercomputing" ? "_blank" : "_self"} target={title === "Supercomputing" ? "_blank" : "_self"}
> >
<div class="skeleton absolute inset-0 rounded-[1.5vw] z-0" />
<Image <Image
src={project.image} src={project.image}
alt={`${title} Project`} alt={`${title} Project`}
@ -41,7 +44,10 @@ import { Image } from "astro:assets";
</div> </div>
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]"> <div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]">
<FaGear data-inview className="in-view:rotate-[500deg] duration-[3000ms] group-hover:rotate-[750deg]" /> <FaGear
data-inview
className="in-view:rotate-[500deg] duration-[3000ms] group-hover:rotate-[750deg]"
/>
</div> </div>
</a> </a>
)) ))
@ -120,4 +126,33 @@ import { Image } from "astro:assets";
initializeProjectCards(); initializeProjectCards();
document.addEventListener("astro:page-load", initializeProjectCards); document.addEventListener("astro:page-load", initializeProjectCards);
// Image loading handler
function handleImageLoading() {
const projectImages = document.querySelectorAll(".project-card img");
projectImages.forEach((image, index) => {
// Ensure the image is fully loaded, even if it's already in cache
if (image.complete) {
image.style.opacity = "1";
const skeleton = image.previousElementSibling;
if (skeleton && skeleton.classList.contains("skeleton")) {
skeleton.style.display = "none";
}
} else {
image.addEventListener("load", () => {
image.style.opacity = "1";
const skeleton = image.previousElementSibling;
if (skeleton && skeleton.classList.contains("skeleton")) {
skeleton.style.display = "none";
}
});
}
});
}
// Ensure images are loaded after a short delay
setTimeout(handleImageLoading, 100);
handleImageLoading();
document.addEventListener("astro:page-load", handleImageLoading);
</script> </script>

View file

@ -11,12 +11,19 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" /> <LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
<p>Quarterly Project</p> <p>Quarterly Project</p>
</div> </div>
<Image <div class="w-[70vw] aspect-[2.5/1] relative">
src={qp} <div
alt="qp showcase photo" id="qp-skeleton"
class="md:opacity-0 md:w-[70vw] w-[85vw] md:aspect-[2.5/1] aspect-[2.5/1.2] rounded-full relative in-view:animate-fade-down" class="skeleton absolute inset-0 rounded-full z-0"
data-inview >
/> <Image
src={qp}
alt="qp showcase photo"
class="md:opacity-0 md:w-[70vw] w-[85vw] md:aspect-[2.5/1] aspect-[2.5/1.2] rounded-full relative in-view:animate-fade-down"
data-inview
/>
</div>
</div>
<Link <Link
data-inview data-inview
href="/projects/quarterly" href="/projects/quarterly"
@ -28,7 +35,8 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
/> />
</Link> </Link>
<div <div
data-inview class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]" data-inview
class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
> >
<p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p> <p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p>
<p> <p>
@ -39,3 +47,16 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
</p> </p>
</div> </div>
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const image = document.getElementById("qp-image");
const skeleton = document.getElementById("qp-skeleton");
if (image) {
image.onload = () => {
skeleton.style.display = "none";
};
}
});
</script>

120
src/config/dashboard.yaml Normal file
View file

@ -0,0 +1,120 @@
sections:
# Base Menu (accessible to all except sponsors)
profile:
title: "Dashboard"
icon: "heroicons:home"
role: "none"
component: "ProfileSection"
class: "text-primary hover:text-primary-focus"
events:
title: "Events"
icon: "heroicons:calendar"
role: "none"
component: "EventsSection"
class: "text-secondary hover:text-secondary-focus"
reimbursement:
title: "Reimbursement"
icon: "heroicons:credit-card"
role: "none"
component: "ReimbursementSection"
class: "text-accent hover:text-accent-focus"
# Officer Menu
eventManagement:
title: "Event Management"
icon: "heroicons:cog-6-tooth"
role: "general"
component: "Officer_EventManagement"
class: "text-info hover:text-info-focus"
reimbursementManagement:
title: "Reimbursement Management"
icon: "heroicons:credit-card"
role: "executive"
component: "Officer_ReimbursementManagement"
class: "text-info hover:text-info-focus"
eventRequestManagement:
title: "Event Request Management"
icon: "heroicons:document-text"
role: "executive"
component: "Officer_EventRequestManagement"
class: "text-info hover:text-info-focus"
eventRequestForm:
title: "Event Request Form"
icon: "heroicons:document-text"
role: "general"
component: "Officer_EventRequestForm"
class: "text-info hover:text-info-focus"
# Sponsor Menu
sponsorDashboard:
title: "Sponsor Dashboard"
icon: "heroicons:briefcase"
role: "sponsor"
component: "SponsorDashboard"
class: "text-warning hover:text-warning-focus"
sponsorAnalytics:
title: "Analytics"
icon: "heroicons:chart-bar"
role: "sponsor"
component: "SponsorAnalytics"
class: "text-warning hover:text-warning-focus"
# Administrator Menu
adminDashboard:
title: "Admin Dashboard"
icon: "heroicons:shield-check"
role: "administrator"
component: "AdminDashboard"
class: "text-error hover:text-error-focus"
# Settings (accessible to all except sponsors)
settings:
title: "Settings"
icon: "heroicons:cog-6-tooth"
role: "none"
component: "SettingsSection"
class: "text-neutral hover:text-neutral-focus"
logout:
title: "Logout"
icon: "heroicons:arrow-left-on-rectangle"
role: "none"
class: "text-error hover:text-error-focus"
# Menu Categories
categories:
main:
title: "Main Menu"
sections: ["profile", "events", "reimbursement"]
role: "none"
officer:
title: "Officer Menu"
sections: ["eventManagement", "eventRequestForm"]
role: "general"
executive:
title: "Executive Menu"
sections: ["reimbursementManagement", "eventRequestManagement"]
role: "executive"
admin:
title: "Admin Menu"
sections: ["adminDashboard"]
role: "administrator"
sponsor:
title: "Sponsor Portal"
sections: ["sponsorDashboard", "sponsorAnalytics"]
role: "sponsor"
account:
title: "Account"
sections: ["settings", "logout"]
role: "none"

View file

@ -0,0 +1,5 @@
api:
baseUrl: https://pocketbase.ieeeucsd.org
oauth2:
redirectPath: /oauth2-redirect
providerName: oidc

View file

@ -0,0 +1,14 @@
status:
- "submitted"
- "under_review"
- "approved"
- "rejected"
- "paid"
- "in_progress"
department:
- "internal"
- "external"
- "projects"
- "events"
- "other"

165
src/data/allUCSDMajors.txt Normal file
View file

@ -0,0 +1,165 @@
Astronomy & Astrophysics
Anthropology
Bioengineering
Biology with Specialization in Bioinformatics
Ecology, Behavior and Evolution
General Biology
Human Biology
Microbiology
Molecular and Cell Biology
Neurobiology / Physiology and Neuroscience
Biochemistry and Cell Biology
Biochemistry
Chemistry
Environmental Chemistry
Molecular Synthesis
Pharmacological Chemistry
Chinese Studies
Cinematic Arts
Classical Studies
Cognitive Science
Cognitive Science Clinical Aspects of Cognition
Cognitive Science Design and Interaction
Cognitive Science Language and Culture
Cognitive Science Machine Learning and Neural Computation
Cognitive Science Neuroscience
Cognitive and Behavioral Neuroscience
Communication
Media Industries and Communication
Computer Engineering
Computer Science
Computer Science Bioinformatics
Critical Gender Studies
Dance
Data Science
Business Economics
Economics
Economics and Mathematics Joint Major
Economics-Public Policy
Education Sciences
Electrical Engineering
Electrical Engineering and Society
Engineering Physics
Environmental Systems (Earth Sciences)
Environmental Systems (Ecology, Behavior & Evolution)
Environmental Systems (Environmental Chemistry)
Environmental Systems (Environmental Policy)
Ethnic Studies
German Studies
Global Health
Global South Studies
Public Health
Public Health Biostatistics
Public Health Climate and Environmental Sciences
Public Health Community Health Sciences
Public Health Epidemiology
Public Health Health Policy and Management Sciences
Public Health Medicine Sciences
History
Human Developmental Sciences
Human Developmental Sciences Equity and Diversity
Human Developmental Sciences Healthy Aging
International Studies Anthropology
International Studies Economics
International Studies Economics (Joint BA/MIA)
International Studies History
International Studies International Business
International Studies International Business (Joint BA/MIA)
International Studies Linguistics
International Studies Literature
International Studies Philosophy
International Studies Political Science
International Studies Political Science (Joint BA/MIA)
International Studies Sociology
Italian Studies
Japanese Studies
Jewish Studies
Latin American Studies
Latin American Studies Mexico
Latin American Studies Migration and Border Studies
Linguistics
Linguistics Cognition and Language
Linguistics Language and Society
Linguistics: Language Studies
Linguistics Speech and Language Sciences
Literary Arts
Literatures in English
Spanish Literature
World Literature and Culture
Mathematical Biology
Mathematics (Applied)
Mathematics
Mathematics Applied Science
Mathematics Computer Science
Mathematics Secondary Education
Probability and Statistics
Aerospace Engineering
Aerospace Engineering Aerothermodynamics
Aerospace Engineering Astrodynamics and Space Applications
Aerospace Engineering Flight Dynamics and Controls
Mechanical Engineering
Mechanical Engineering Controls and Robotics
Mechanical Engineering Fluid Mechanics and Thermal Systems
Mechanical Engineering Materials Science and Engineering
Mechanical Engineering Mechanics of Materials
Mechanical Engineering Renewable Energy and Environmental Flows
Music
Music Humanities
Interdisciplinary Computing and the Arts
Chemical Engineering
NanoEngineering
Philosophy
General Physics
General Physics/Secondary Education
Physics
Physics Astrophysics
Physics Biophysics
Physics Computational Physics
Physics Earth Sciences
Physics Materials Physics
Political Science
Political Science American Politics
Political Science Comparative Politics
Political Science Data Analytics
Political Science International Affairs
Political Science International Relations
Political Science Political Theory
Political Science Public Law
Political Science Public Policy
Political Science Race, Ethnicity, and Politics
Psychology
Psychology Clinical Psychology
Psychology Cognitive Psychology
Psychology Developmental Psychology
Psychology Human Health
Psychology Sensation and Perception
Psychology Social Psychology
Business Psychology
Study of Religion
Russian, East European & Eurasian Studies
Geosciences
Marine Biology
Oceanic and Atmospheric Sciences
Sociology
Sociology International Studies
Sociology American Studies
Sociology Science and Medicine
Sociology Economy and Society
Sociology Culture and Communication
Sociology Social Inequality
Sociology Law and Society
Structural Engineering
Structural Engineering Aerospace Structures
Structural Engineering Civil Structures
Structural Engineering Geotechnical Engineering
Structural Engineering Structural Health Monitoring/Non-Destructive Evaluation
Theatre
Undeclared Humanities/Arts
Undeclared Physical Sciences
Undeclared Social Sciences
Urban Studies and Planning
Real Estate and Development
Art History/Criticism
Media
Speculative Design
Studio

View file

@ -97,13 +97,6 @@
"email": "Erduarte@ucsd.edu", "email": "Erduarte@ucsd.edu",
"type": ["Executives", "Events"] "type": ["Executives", "Events"]
}, },
{
"name": "Sin Yin Yang",
"position": "Events Coordinator",
"picture": "https://placehold.co/800x800?text=SIN%20YIN%20YANG",
"email": "siy015@ucsd.edu",
"type": ["Executives", "Events"]
},
{ {
"name": "Rafaella Gomes", "name": "Rafaella Gomes",
"position": "Project Space Chair", "position": "Project Space Chair",

View file

@ -38,7 +38,7 @@
"path": "/find" "path": "/find"
}, },
{ {
"name": "Online Store", "name": "Dashboard",
"path": "/online-store" "path": "/dashboard"
} }
] ]

View file

@ -5,24 +5,38 @@ import InView from "../components/core/InView.astro";
--- ---
<!doctype html> <!doctype html>
<html lang="en" class="w-full h-full m-0 bg-ieee-black"> <html lang="en" data-theme="dark" class="w-full h-full m-0 bg-ieee-black">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Astro Basics</title> <title>IEEEUCSD</title>
</head> <script
<InView /> src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"
<body class="w-full h-full m-0 bg-ieee-black"> ></script>
<div class="text-white min-h-screen"> <script is:inline>
<header class="sticky top-0 w-full z-[999]"> // Set default theme to dark if not already set
<Navbar /> if (!localStorage.getItem("theme")) {
</header> localStorage.setItem("theme", "dark");
<main class="w-[95%] mx-auto"> document.documentElement.setAttribute("data-theme", "dark");
<slot /> } else {
</main> // Apply saved theme
<Footer /> const savedTheme = localStorage.getItem("theme");
</div> document.documentElement.setAttribute("data-theme", savedTheme);
</body> }
</script>
</head>
<InView />
<body class="w-full h-full m-0 bg-ieee-black">
<div class="text-white min-h-screen">
<header class="sticky top-0 w-full z-[999]">
<Navbar />
</header>
<main class="w-[95%] mx-auto">
<slot />
</main>
<Footer />
</div>
</body>
</html> </html>

5
src/lib/pocketbase.ts Normal file
View file

@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase(import.meta.env.PUBLIC_POCKETBASE_URL);
export default pb;

889
src/pages/dashboard.astro Normal file
View file

@ -0,0 +1,889 @@
---
import yaml from "js-yaml";
import { Icon } from "astro-icon/components";
import fs from "node:fs";
import path from "node:path";
import { Authentication } from "../scripts/pocketbase/Authentication";
import { Get } from "../scripts/pocketbase/Get";
import { SendLog } from "../scripts/pocketbase/SendLog";
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
import { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync";
import ToastProvider from "../components/dashboard/universal/ToastProvider";
const title = "Dashboard";
// Load and parse dashboard config
const configPath = path.join(process.cwd(), "src", "config", "dashboard.yaml");
const dashboardConfig = yaml.load(fs.readFileSync(configPath, "utf8")) as any;
// Dynamically import all dashboard components
const components = Object.fromEntries(
await Promise.all(
Object.values(dashboardConfig.sections)
.filter((section: any) => section.component) // Only process sections with components
.map(async (section: any) => {
const component = await import(
`../components/dashboard/${section.component}.astro`
);
console.log(`Loaded component: ${section.component}`); // Debug log
return [section.component, component.default];
})
)
);
console.log("Available components:", Object.keys(components)); // Debug log
---
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} | IEEE UCSD</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script
src="https://code.iconify.design/iconify-icon/2.3.0/iconify-icon.min.js"
></script>
</head>
<body class="bg-base-200">
<div class="flex h-screen">
<!-- Sidebar -->
<aside
class="bg-base-100 w-80 flex flex-col shadow-xl border-r border-base-200 transition-all duration-300 fixed xl:relative h-full z-50 -translate-x-full xl:translate-x-0"
>
<!-- Logo -->
<div class="p-6 border-b border-base-200">
<div class="flex items-center justify-center">
<span
class="text-4xl font-bold text-[#06659d] select-none tracking-wide"
>IEEEUCSD</span
>
</div>
</div>
<!-- User Profile -->
<div class="p-6 border-b border-base-200">
<!-- Loading State -->
<div
id="userProfileSkeleton"
class="flex items-center gap-4"
>
<div class="avatar flex items-center justify-center">
<div
class="w-12 h-12 rounded-xl bg-base-300 animate-pulse"
>
</div>
</div>
<div class="flex-1">
<div
class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2"
>
</div>
<div
class="h-5 w-20 bg-base-300 animate-pulse rounded"
>
</div>
</div>
</div>
<!-- Signed Out State -->
<div
id="userProfileSignedOut"
class="flex items-center gap-4 hidden"
>
<div class="avatar flex items-center justify-center">
<div
class="w-12 h-12 rounded-xl bg-base-300 text-base-content/30 flex items-center justify-center"
>
<Icon name="heroicons:user" class="h-6 w-6" />
</div>
</div>
<div>
<h3
class="font-medium text-lg text-base-content/70"
>
Signed Out
</h3>
<div class="badge badge-outline mt-1 opacity-50">
Guest
</div>
</div>
</div>
<!-- Actual Profile -->
<div
id="userProfileSummary"
class="flex items-center gap-4 hidden"
>
<div class="avatar flex items-center justify-center">
<div
class="w-12 h-12 rounded-xl bg-[#06659d] text-white ring ring-base-200 ring-offset-base-100 ring-offset-2 inline-flex items-center justify-center"
>
<span
class="text-xl font-semibold select-none inline-flex items-center justify-center w-full h-full"
id="userInitials">?</span
>
</div>
</div>
<div>
<h3 class="font-medium text-lg" id="userName">
Loading...
</h3>
<div
class="badge badge-outline mt-1 border-[#06659d] text-[#06659d]"
id="userRole"
>
Member
</div>
</div>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto scrollbar-hide py-6">
<ul class="menu gap-2 px-4 text-base-content/80">
<!-- Loading Skeleton -->
<div id="menuLoadingSkeleton">
{
[1, 2, 3].map((group) => (
<>
<li class="menu-title font-medium opacity-70">
<div class="h-4 w-24 bg-base-300 animate-pulse rounded" />
</li>
{[1, 2, 3].map((item) => (
<li>
<div class="flex items-center gap-4 py-2">
<div class="h-5 w-5 bg-base-300 animate-pulse rounded" />
<div class="h-4 w-32 bg-base-300 animate-pulse rounded" />
</div>
</li>
))}
</>
))
}
</div>
<!-- Actual Menu -->
<div id="actualMenu" class="hidden">
{
Object.entries(dashboardConfig.categories).map(
([categoryKey, category]: [
string,
any,
]) => (
<>
<li
class={`menu-title font-medium opacity-70 ${
category.role &&
category.role !== "none"
? "hidden"
: ""
}`}
data-role-required={
category.role || "none"
}
>
<span>{category.title}</span>
</li>
{category.sections.map(
(sectionKey: string) => {
const section =
dashboardConfig
.sections[
sectionKey
];
return (
<li
class={
section.role &&
section.role !==
"none"
? "hidden"
: ""
}
data-role-required={
section.role
}
>
<button
class={`dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 ${section.class || ""}`}
data-section={
sectionKey
}
>
<Icon
name={
section.icon
}
class="h-5 w-5"
/>
{section.title}
</button>
</li>
);
}
)}
</>
)
)
}
</div>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main
class="flex-1 overflow-x-hidden overflow-y-auto bg-base-200 w-full xl:w-[calc(100%-20rem)]"
>
<!-- Mobile Header -->
<header
class="bg-base-100 p-4 shadow-md xl:hidden sticky top-0 z-40"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button
id="mobileSidebarToggle"
class="btn btn-square btn-ghost"
>
<Icon name="heroicons:bars-3" class="h-6 w-6" />
</button>
<h1 class="text-xl font-bold">IEEE UCSD</h1>
</div>
</div>
</header>
<!-- Page Content -->
<div class="p-4 md:p-6 max-w-[1600px] mx-auto">
<!-- Loading State -->
<div id="pageLoadingState" class="w-full">
<div
class="flex flex-col items-center justify-center p-4 sm:p-8"
>
<div class="loading loading-spinner loading-lg">
</div>
<p class="mt-4 opacity-70">Loading dashboard...</p>
</div>
</div>
<!-- Error State -->
<div id="pageErrorState" class="hidden w-full">
<div class="alert alert-error mx-2 sm:mx-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
<span>Failed to load dashboard content</span>
</div>
</div>
<!-- Not Authenticated State -->
<div id="notAuthenticatedState" class="hidden w-full">
<div class="card bg-base-100 shadow-xl mx-2 sm:mx-0">
<div
class="card-body items-center text-center p-4 sm:p-8"
>
<div class="mb-4 sm:mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 sm:h-16 sm:w-16 opacity-30"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd"></path>
</svg>
</div>
<h2 class="card-title text-xl sm:text-2xl mb-2">
Sign in to Access Dashboard
</h2>
<p
class="opacity-70 mb-4 sm:mb-6 text-sm sm:text-base"
>
Please sign in with your IEEE UCSD account
to access the dashboard.
</p>
<button
class="login-button btn btn-primary btn-lg gap-2 w-full sm:w-auto"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M3 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1zm7.707 3.293a1 1 0 010 1.414L9.414 9H17a1 1 0 110 2H9.414l1.293 1.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z"
clip-rule="evenodd"></path>
</svg>
<span class="whitespace-nowrap"
>Sign in with IEEEUCSD SSO</span
>
</button>
</div>
</div>
</div>
<!-- Main Content -->
<div id="mainContent" class="space-y-4 sm:space-y-6">
{
Object.entries(dashboardConfig.sections).map(
([sectionKey, section]: [string, any]) => {
// Skip if no component is defined
if (!section.component) return null;
const Component =
components[section.component];
return (
<div
id={`${sectionKey}Section`}
class={`dashboard-section hidden ${
section.role &&
section.role !== "none"
? "role-restricted"
: ""
}`}
data-role-required={section.role}
>
<Component />
</div>
);
}
)
}
</div>
</div>
</main>
</div>
<!-- Centralized Toast Provider -->
<ToastProvider client:load />
<script>
import { Authentication } from "../scripts/pocketbase/Authentication";
import { Get } from "../scripts/pocketbase/Get";
import { SendLog } from "../scripts/pocketbase/SendLog";
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
import { OfficerTypes } from "../schemas/pocketbase/schema";
import { initAuthSync } from "../scripts/database/initAuthSync";
const auth = Authentication.getInstance();
const get = Get.getInstance();
const logger = SendLog.getInstance();
// Initialize page state
const pageLoadingState =
document.getElementById("pageLoadingState");
const pageErrorState = document.getElementById("pageErrorState");
const notAuthenticatedState = document.getElementById(
"notAuthenticatedState"
);
const mainContent = document.getElementById("mainContent");
const sidebar = document.querySelector("aside");
// User profile elements
const userInitials = document.getElementById("userInitials");
const userName = document.getElementById("userName");
const userRole = document.getElementById("userRole");
// Function to update section visibility based on role
const updateSectionVisibility = (officerStatus: OfficerStatus) => {
// Special handling for sponsor role
if (officerStatus === "sponsor") {
// Hide all sections first
document
.querySelectorAll("[data-role-required]")
.forEach((element) => {
element.classList.add("hidden");
});
// Only show sponsor sections
document
.querySelectorAll('[data-role-required="sponsor"]')
.forEach((element) => {
element.classList.remove("hidden");
});
return;
}
// For non-sponsor roles, handle normally
document
.querySelectorAll("[data-role-required]")
.forEach((element) => {
const requiredRole = element.getAttribute(
"data-role-required"
) as OfficerStatus;
// Skip elements that don't have a role requirement
if (!requiredRole || requiredRole === "none") {
element.classList.remove("hidden");
return;
}
// Check if user has permission for this role
const hasPermission = hasAccess(
officerStatus,
requiredRole
);
// Only show elements if user has permission
element.classList.toggle("hidden", !hasPermission);
});
};
// Handle navigation
const handleNavigation = () => {
const navButtons =
document.querySelectorAll(".dashboard-nav-btn");
const sections =
document.querySelectorAll(".dashboard-section");
const mainContentDiv = document.getElementById("mainContent");
// Ensure mainContent is visible
if (mainContentDiv) {
mainContentDiv.classList.remove("hidden");
}
navButtons.forEach((button) => {
button.addEventListener("click", () => {
const sectionKey = button.getAttribute("data-section");
// Handle logout button
if (sectionKey === "logout") {
auth.logout();
window.location.reload();
return;
}
// Remove active class from all buttons
navButtons.forEach((btn) => {
btn.classList.remove("active", "bg-base-200");
});
// Add active class to clicked button
button.classList.add("active", "bg-base-200");
// Hide all sections
sections.forEach((section) => {
section.classList.add("hidden");
});
// Show selected section
const sectionId = `${sectionKey}Section`;
const targetSection =
document.getElementById(sectionId);
if (targetSection) {
targetSection.classList.remove("hidden");
console.log(`Showing section: ${sectionId}`); // Debug log
}
// Close mobile sidebar if needed
if (window.innerWidth < 1024 && sidebar) {
sidebar.classList.add("-translate-x-full");
document.body.classList.remove("overflow-hidden");
const overlay =
document.getElementById("sidebarOverlay");
overlay?.remove();
}
});
});
};
// Display user profile information and handle role-based access
const updateUserProfile = async (user: { id: string }) => {
if (!user) return;
try {
// Use fields from the User interface in the schema
const extendedUser = await get.getOne("users", user.id, {
fields: [
"id",
"name",
"email",
"verified",
"avatar",
"pid",
"member_id",
"graduation_year",
"major",
],
});
const displayName = extendedUser.name || "Unknown User";
// Default role is Member
let displayRole = "Member";
// Map the officer type from the database to our OfficerStatus type
let officerStatus: OfficerStatus = "";
// Get the officer record for this user if it exists
// Use fields from the Officer interface in the schema
const officerRecords = await get.getList(
"officers",
1,
50,
`user="${user.id}"`,
"",
{
fields: ["id", "type", "role"],
}
);
if (officerRecords && officerRecords.items.length > 0) {
const officerType = officerRecords.items[0].type;
const officerRole = officerRecords.items[0].role;
// Use the role field from the officer's collection
if (officerRole) {
displayRole = officerRole;
}
// Map the officer type to our OfficerStatus
switch (officerType) {
case OfficerTypes.ADMINISTRATOR:
officerStatus = "administrator";
break;
case OfficerTypes.EXECUTIVE:
officerStatus = "executive";
break;
case OfficerTypes.GENERAL:
officerStatus = "general";
break;
case OfficerTypes.HONORARY:
officerStatus = "honorary";
break;
case OfficerTypes.PAST:
officerStatus = "past";
break;
default:
officerStatus = "none";
}
} else {
// Check if user is a sponsor by querying the sponsors collection
const sponsorRecords = await get.getList(
"sponsors",
1,
1,
`user="${user.id}"`,
"",
{
fields: ["id", "company"],
}
);
if (sponsorRecords && sponsorRecords.items.length > 0) {
officerStatus = "sponsor";
displayRole = "Sponsor";
} else {
officerStatus = "none";
}
}
const initials = (extendedUser.name || "U")
.split(" ")
.map((n: string) => n[0])
.join("")
.toUpperCase();
// Update profile display
if (userName) userName.textContent = displayName;
if (userRole) userRole.textContent = displayRole;
if (userInitials) userInitials.textContent = initials;
// Update section visibility based on role
updateSectionVisibility(officerStatus);
} catch (error) {
console.error("Error fetching user profile:", error);
const fallbackValues = {
name: "Unknown User",
role: "Member",
initials: "?",
};
if (userName) userName.textContent = fallbackValues.name;
if (userRole) userRole.textContent = fallbackValues.role;
if (userInitials)
userInitials.textContent = fallbackValues.initials;
updateSectionVisibility("" as OfficerStatus);
}
};
// Mobile sidebar toggle
const mobileSidebarToggle = document.getElementById(
"mobileSidebarToggle"
);
if (mobileSidebarToggle && sidebar) {
const toggleSidebar = () => {
const isOpen =
!sidebar.classList.contains("-translate-x-full");
if (isOpen) {
sidebar.classList.add("-translate-x-full");
document.body.classList.remove("overflow-hidden");
const overlay =
document.getElementById("sidebarOverlay");
overlay?.remove();
} else {
sidebar.classList.remove("-translate-x-full");
document.body.classList.add("overflow-hidden");
const overlay = document.createElement("div");
overlay.id = "sidebarOverlay";
overlay.className =
"fixed inset-0 bg-black bg-opacity-50 z-40 xl:hidden";
overlay.addEventListener("click", toggleSidebar);
document.body.appendChild(overlay);
}
};
mobileSidebarToggle.addEventListener("click", toggleSidebar);
}
// Function to initialize the page
const initializePage = async () => {
try {
// Initialize auth sync for IndexedDB
await initAuthSync();
// Check if user is authenticated
if (!auth.isAuthenticated()) {
console.log("User not authenticated");
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
if (notAuthenticatedState)
notAuthenticatedState.classList.remove("hidden");
return;
}
if (pageLoadingState)
pageLoadingState.classList.remove("hidden");
if (pageErrorState) pageErrorState.classList.add("hidden");
if (notAuthenticatedState)
notAuthenticatedState.classList.add("hidden");
// Show loading states
const userProfileSkeleton = document.getElementById(
"userProfileSkeleton"
);
const userProfileSignedOut = document.getElementById(
"userProfileSignedOut"
);
const userProfileSummary =
document.getElementById("userProfileSummary");
const menuLoadingSkeleton = document.getElementById(
"menuLoadingSkeleton"
);
const actualMenu = document.getElementById("actualMenu");
if (userProfileSkeleton)
userProfileSkeleton.classList.remove("hidden");
if (userProfileSummary)
userProfileSummary.classList.add("hidden");
if (userProfileSignedOut)
userProfileSignedOut.classList.add("hidden");
if (menuLoadingSkeleton)
menuLoadingSkeleton.classList.remove("hidden");
if (actualMenu) actualMenu.classList.add("hidden");
const user = auth.getCurrentUser();
await updateUserProfile(user);
// Show actual profile and hide skeleton
if (userProfileSkeleton)
userProfileSkeleton.classList.add("hidden");
if (userProfileSummary)
userProfileSummary.classList.remove("hidden");
// Hide all sections first
document
.querySelectorAll(".dashboard-section")
.forEach((section) => {
section.classList.add("hidden");
});
// Show appropriate default section based on role
// Get the officer record for this user if it exists
let officerStatus: OfficerStatus = "none";
try {
const officerRecords = await get.getList(
"officers",
1,
50,
`user="${user.id}"`,
"",
{
fields: ["id", "type", "role"],
}
);
if (officerRecords && officerRecords.items.length > 0) {
const officerType = officerRecords.items[0].type;
// We can also get the role here if needed for display elsewhere
const officerRole = officerRecords.items[0].role;
// Map the officer type to our OfficerStatus
switch (officerType) {
case OfficerTypes.ADMINISTRATOR:
officerStatus = "administrator";
break;
case OfficerTypes.EXECUTIVE:
officerStatus = "executive";
break;
case OfficerTypes.GENERAL:
officerStatus = "general";
break;
case OfficerTypes.HONORARY:
officerStatus = "honorary";
break;
case OfficerTypes.PAST:
officerStatus = "past";
break;
default:
officerStatus = "none";
}
} else {
// Check if user is a sponsor by querying the sponsors collection
const sponsorRecords = await get.getList(
"sponsors",
1,
1,
`user="${user.id}"`,
"",
{
fields: ["id", "company"],
}
);
if (
sponsorRecords &&
sponsorRecords.items.length > 0
) {
officerStatus = "sponsor";
} else {
officerStatus = "none";
}
}
} catch (error) {
console.error(
"Error determining officer status:",
error
);
officerStatus = "none";
}
let defaultSection;
let defaultButton;
if (officerStatus === "sponsor") {
defaultSection = document.getElementById(
"sponsorDashboardSection"
);
defaultButton = document.querySelector(
'[data-section="sponsorDashboard"]'
);
} else if (officerStatus === "administrator") {
defaultSection = document.getElementById(
"adminDashboardSection"
);
defaultButton = document.querySelector(
'[data-section="adminDashboard"]'
);
} else {
defaultSection =
document.getElementById("profileSection");
defaultButton = document.querySelector(
'[data-section="profile"]'
);
}
if (defaultSection) {
defaultSection.classList.remove("hidden");
}
if (defaultButton) {
defaultButton.classList.add("active", "bg-base-200");
}
// Initialize navigation
handleNavigation();
// Show actual menu and hide skeleton
if (menuLoadingSkeleton)
menuLoadingSkeleton.classList.add("hidden");
if (actualMenu) actualMenu.classList.remove("hidden");
// Show main content and hide loading
if (mainContent) mainContent.classList.remove("hidden");
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
} catch (error) {
console.error("Error initializing dashboard:", error);
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
if (pageErrorState)
pageErrorState.classList.remove("hidden");
}
};
// Initialize when DOM is loaded
document.addEventListener("DOMContentLoaded", initializePage);
// Handle login button click
document
.querySelector(".login-button")
?.addEventListener("click", async () => {
try {
if (pageLoadingState)
pageLoadingState.classList.remove("hidden");
if (notAuthenticatedState)
notAuthenticatedState.classList.add("hidden");
await auth.login();
} catch (error) {
console.error("Login error:", error);
if (pageLoadingState)
pageLoadingState.classList.add("hidden");
if (pageErrorState)
pageErrorState.classList.remove("hidden");
}
});
// Handle logout button click
document
.getElementById("logoutButton")
?.addEventListener("click", () => {
auth.logout();
window.location.reload();
});
// Handle responsive sidebar
if (sidebar) {
if (window.innerWidth < 1024) {
sidebar.classList.add("-translate-x-full");
}
window.addEventListener("resize", () => {
if (window.innerWidth >= 1024) {
const overlay =
document.getElementById("sidebarOverlay");
if (overlay) {
overlay.remove();
document.body.classList.remove("overflow-hidden");
}
sidebar.classList.remove("-translate-x-full");
}
});
}
</script>
</body>
</html>

View file

@ -9,24 +9,23 @@ import contactbroder from "../images/contactbroder.webp";
--- ---
<Layout> <Layout>
<Image <Image
src={contactbroder} src={contactbroder}
alt="JOIN US" alt="JOIN US"
class="absolute left-[1vw] md:w-[4vw] w-[5vw] md:top-[60vh] top-[70vw] -z-10" class="absolute left-[1vw] md:w-[4vw] w-[5vw] md:top-[60vh] top-[70vw] -z-10"
/> />
<Image <Image
src={contactbroder} src={contactbroder}
alt="JOIN US" alt="JOIN US"
class="absolute right-[1vw] md:w-[4vw] w-[5vw] md:top-[60vh] top-[70vw] -z-10" class="absolute right-[1vw] md:w-[4vw] w-[5vw] md:top-[60vh] top-[70vw] -z-10"
/> />
<FindTitle /> <FindTitle />
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<Image <div class="skeleton w-3/4 rounded-full">
src={event} <Image src={event} alt="board group photos" class="w-full rounded-full" />
alt="board group photos"
class="w-[90%] md:w-3/4 rounded-full"
/>
</div> </div>
<Findus /> </div>
<Social />
<Findus />
<Social />
</Layout> </Layout>

View file

@ -0,0 +1,17 @@
---
import Layout from "../layouts/Layout.astro";
const title = "Authenticating...";
---
<main class="min-h-screen flex items-center justify-center">
<div id="content" class="text-center">
<p class="text-2xl font-medium">Redirecting to dashboard...</p>
<div class="mt-4">
<div class="loading loading-spinner loading-lg"></div>
</div>
</div>
</main>
<script>
import { RedirectHandler } from "../scripts/auth/RedirectHandler";
new RedirectHandler();
</script>

View file

@ -0,0 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout>
<h1>Reimbursement</h1>
</Layout>

View file

@ -0,0 +1,46 @@
# PocketBase Schema
This directory contains the schema definition for the PocketBase database used in the IEEE UCSD website.
## Overview
The `schema.ts` file defines TypeScript interfaces that represent the collections in the PocketBase database. These interfaces can be imported and used throughout the codebase to ensure type safety when working with PocketBase data.
## Collections
The following collections are defined in the schema:
- **Users**: User accounts in the system
- **Events**: Events created in the system
- **Event Requests**: Requests to create new events
- **Logs**: System logs for user actions
- **Officers**: Officer roles in the organization
- **Reimbursements**: Reimbursement requests
- **Receipts**: Receipt records for reimbursements
- **Sponsors**: Sponsors of the organization
## Usage
To use these types in your code, import them from the schema file:
```typescript
import { User, Event, Collections } from "../pocketbase/schema";
// Example: Get a user from PocketBase
const getUser = async (userId: string): Promise<User> => {
const pb = getPocketBase();
return await pb.collection(Collections.USERS).getOne<User>(userId);
};
```
## Updating the Schema
When the PocketBase database schema changes, update the corresponding interfaces in `schema.ts` to reflect those changes. This ensures that the TypeScript types match the actual database structure.
## Collection Names
The `Collections` object provides constants for all collection names, which should be used when making API calls to PocketBase instead of hardcoding collection names as strings.
## Collection IDs
Each collection has its PocketBase collection ID documented in the schema file. These IDs are useful for reference and debugging purposes.

View file

@ -0,0 +1,8 @@
/**
* PocketBase Schema Index
*
* This file re-exports all types and constants from the schema file
* for easier imports throughout the codebase.
*/
export * from "./schema";

View file

@ -0,0 +1,267 @@
/**
* PocketBase Collections Schema
*
* This file documents the schema for all collections in the PocketBase database.
* It is based on the interfaces found throughout the codebase.
*/
/**
* Base interface for all PocketBase records
*/
export interface BaseRecord {
id: string;
created: string;
updated: string;
}
/**
* Users Collection
* Represents user accounts in the system
* Collection ID: _pb_users_auth_
*/
export interface User extends BaseRecord {
email: string;
emailVisibility: boolean;
verified: boolean;
name: string;
avatar?: string;
pid?: string;
member_id?: string;
graduation_year?: number;
major?: string;
zelle_information?: string;
last_login?: string;
points?: number; // Total points earned from events
notification_preferences?: string; // JSON string of notification settings
display_preferences?: string; // JSON string of display settings (theme, font size, etc.)
accessibility_settings?: string; // JSON string of accessibility settings (color blind mode, reduced motion)
}
/**
* Events Collection
* Represents events created in the system
* Collection ID: pbc_1687431684
*/
export interface Event extends BaseRecord {
event_name: string;
event_description: string;
event_code: string;
location: string;
files: string[];
points_to_reward: number;
start_date: string;
end_date: string;
published: boolean;
has_food: boolean;
}
/**
* Attendee Entry
* Represents an attendee record for an event
* This is stored as part of the Event record
*/
export interface AttendeeEntry {
user_id: string;
time_checked_in: string;
food: string;
}
/**
* Event Attendees Collection
* Represents attendees for events
* Collection ID: pbc_537966730
*/
export interface EventAttendee extends BaseRecord {
user: string; // Relation to User
event: string; // Relation to Event
food_ate: string;
time_checked_in: string;
points_earned: number;
}
/**
* Event Requests Collection
* Represents requests to create new events
* Collection ID: pbc_1475615553
*/
export interface EventRequest extends BaseRecord {
name: string;
location: string;
start_date_time: string;
end_date_time: string;
event_description: string;
flyers_needed: boolean;
flyer_type?: string[]; // digital_with_social, digital_no_social, physical_with_advertising, physical_no_advertising, newsletter, other
other_flyer_type?: string;
flyer_advertising_start_date?: string;
flyer_additional_requests?: string;
photography_needed: boolean;
required_logos?: string[]; // IEEE, AS, HKN, TESC, PIB, TNT, SWE, OTHER
other_logos?: string[];
advertising_format?: string;
will_or_have_room_booking?: boolean;
expected_attendance?: number;
room_booking?: string;
as_funding_required: boolean;
food_drinks_being_served: boolean;
itemized_invoice?: string; // JSON string
invoice?: string;
invoice_files?: string[]; // Array of invoice file IDs
needs_graphics?: boolean;
needs_as_funding?: boolean;
status: "submitted" | "pending" | "completed" | "declined";
requested_user?: string;
}
/**
* Logs Collection
* Represents system logs for user actions
* Collection ID: pbc_3615662572
*/
export interface Log extends BaseRecord {
user: string; // Relation to User
type: string; // Standard types: "error", "update", "delete", "create", "login", "logout"
part: string; // The specific part/section being logged
message: string;
}
/**
* Officers Collection
* Represents officer roles in the organization
* Collection ID: pbc_1036312343
*/
export interface Officer extends BaseRecord {
user: string; // Relation to User
role: string;
type: "administrator" | "executive" | "general" | "honorary" | "past";
}
/**
* Reimbursements Collection
* Represents reimbursement requests
* Collection ID: pbc_2573806534
*/
export interface Reimbursement extends BaseRecord {
title: string;
total_amount: number;
date_of_purchase: string;
payment_method: string;
status:
| "submitted"
| "under_review"
| "approved"
| "rejected"
| "in_progress"
| "paid";
submitted_by: string; // Relation to User
additional_info: string;
receipts: string[]; // Array of Receipt IDs (Relations)
department: "internal" | "external" | "projects" | "events" | "other";
audit_notes?: string | null; // JSON string for user-submitted notes
audit_logs?: string | null; // JSON string for system-generated logs
}
/**
* Receipts Collection
* Represents receipt records for reimbursements
* Collection ID: pbc_1571142587
*/
export interface Receipt extends BaseRecord {
file: string; // Single file
created_by: string; // Relation to User
itemized_expenses: string; // JSON string of ItemizedExpense[]
tax: number;
date: string;
location_name: string;
location_address: string;
notes: string;
audited_by: string; // Relation to User
}
/**
* Sponsors Collection
* Represents sponsors of the organization
* Collection ID: pbc_3665759510
*/
export interface Sponsor extends BaseRecord {
user: string; // Relation to User
company: string;
}
/**
* Itemized Expense
* Represents an individual expense item in a receipt
* This is stored as part of the Receipt record as a JSON string
*/
export interface ItemizedExpense {
description: string;
category: string;
amount: number;
}
/**
* Collection Names
* Constants for the collection names used in the PocketBase API
*/
export const Collections = {
USERS: "users",
EVENTS: "events",
EVENT_REQUESTS: "event_request",
EVENT_ATTENDEES: "event_attendees",
LOGS: "logs",
OFFICERS: "officers",
REIMBURSEMENTS: "reimbursement",
RECEIPTS: "receipts",
SPONSORS: "sponsors",
};
/**
* Flyer Type Options
* Constants for the flyer type options used in event requests
*/
export const FlyerTypes = {
DIGITAL_WITH_SOCIAL: "digital_with_social",
DIGITAL_NO_SOCIAL: "digital_no_social",
PHYSICAL_WITH_ADVERTISING: "physical_with_advertising",
PHYSICAL_NO_ADVERTISING: "physical_no_advertising",
NEWSLETTER: "newsletter",
OTHER: "other",
};
/**
* Logo Options
* Constants for the logo options used in event requests
*/
export const LogoOptions = {
IEEE: "IEEE",
AS: "AS",
HKN: "HKN",
TESC: "TESC",
PIB: "PIB",
TNT: "TNT",
SWE: "SWE",
OTHER: "OTHER",
};
/**
* Event Request Status Options
* Constants for the status options used in event requests
*/
export const EventRequestStatus = {
SUBMITTED: "submitted",
PENDING: "pending",
COMPLETED: "completed",
DECLINED: "declined",
};
/**
* Officer Type Options
* Constants for the officer type options
*/
export const OfficerTypes = {
ADMINISTRATOR: "administrator",
EXECUTIVE: "executive",
GENERAL: "general",
HONORARY: "honorary",
PAST: "past",
};

View file

@ -0,0 +1,118 @@
import PocketBase from "pocketbase";
export class RedirectHandler {
private pb: PocketBase;
private contentEl: HTMLElement;
private params: URLSearchParams;
private provider: any;
constructor() {
this.pb = new PocketBase("https://pocketbase.ieeeucsd.org");
this.contentEl = this.getContentElement();
this.params = new URLSearchParams(window.location.search);
this.provider = this.getStoredProvider();
this.handleRedirect();
}
private getContentElement(): HTMLElement {
const contentEl = document.getElementById("content");
if (!contentEl) {
throw new Error("Content element not found");
}
return contentEl;
}
private getStoredProvider() {
return JSON.parse(localStorage.getItem("provider") || "{}");
}
private showError(message: string) {
this.contentEl.innerHTML = `
<p class='text-red-500 text-2xl font-medium mb-4'>${message}</p>
<a href="/" class="btn btn-primary">
Return to Home
</a>
`;
}
private async handleRedirect() {
const code = this.params.get("code");
const state = this.params.get("state");
if (!code) {
this.showError("No authorization code found in URL.");
return;
}
if (state !== this.provider.state) {
this.showError("Invalid state parameter.");
return;
}
try {
const authData = await this.pb
.collection("users")
.authWithOAuth2Code(
"oidc",
code,
this.provider.codeVerifier,
window.location.origin + "/oauth2-redirect",
{ emailVisibility: false },
);
console.log("Auth successful:", authData);
this.contentEl.innerHTML = `
<p class="text-3xl font-bold text-green-500 mb-4">Authentication Successful!</p>
<p class="text-2xl font-medium">Initializing your data...</p>
<div class="mt-4">
<div class="loading loading-spinner loading-lg"></div>
</div>
`;
try {
// Update last login before redirecting
await this.pb.collection("users").update(authData.record.id, {
last_login: new Date().toISOString(),
});
// Initialize data sync
await this.initializeDataSync();
// Clean up and redirect
localStorage.removeItem("provider");
window.location.href = "/dashboard";
} catch (err) {
console.error("Failed to update last login or sync data:", err);
// Still redirect even if last_login update fails
localStorage.removeItem("provider");
window.location.href = "/dashboard";
}
} catch (err: any) {
console.error("Auth error:", err);
this.showError(`Failed to complete authentication: ${err.message}`);
}
}
/**
* Initialize data synchronization after successful authentication
*/
private async initializeDataSync(): Promise<void> {
try {
// Dynamically import the AuthSyncService to avoid circular dependencies
const { AuthSyncService } = await import('../database/AuthSyncService');
// Get the instance and trigger a full sync
const authSync = AuthSyncService.getInstance();
const syncResult = await authSync.handleLogin();
if (syncResult) {
console.log('Initial data sync completed successfully');
} else {
console.warn('Initial data sync completed with issues');
}
} catch (error) {
console.error('Failed to initialize data sync:', error);
// Continue with login process even if sync fails
}
}
}

View file

@ -0,0 +1,327 @@
import { Authentication } from "../pocketbase/Authentication";
import { DataSyncService } from "./DataSyncService";
import { DexieService } from "./DexieService";
import { Collections } from "../../schemas/pocketbase/schema";
import { SendLog } from "../pocketbase/SendLog";
// Check if we're in a browser environment
const isBrowser =
typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
/**
* Service to handle data synchronization during authentication flows
*/
export class AuthSyncService {
private static instance: AuthSyncService;
private auth: Authentication;
private dataSync: DataSyncService;
private dexieService: DexieService;
private logger: SendLog;
private isSyncing: boolean = false;
private syncErrors: Record<string, Error> = {};
private syncQueue: string[] = [];
private syncPromise: Promise<void> | null = null;
// Collections to sync on login
private readonly collectionsToSync = [
Collections.USERS,
Collections.EVENTS,
Collections.EVENT_REQUESTS,
Collections.LOGS,
Collections.OFFICERS,
Collections.REIMBURSEMENTS,
Collections.RECEIPTS,
Collections.SPONSORS,
];
private constructor() {
this.auth = Authentication.getInstance();
this.dataSync = DataSyncService.getInstance();
this.dexieService = DexieService.getInstance();
this.logger = SendLog.getInstance();
// Listen for auth state changes only in browser
if (isBrowser) {
this.auth.onAuthStateChange(this.handleAuthStateChange.bind(this));
}
}
/**
* Get the singleton instance of AuthSyncService
*/
public static getInstance(): AuthSyncService {
if (!AuthSyncService.instance) {
AuthSyncService.instance = new AuthSyncService();
}
return AuthSyncService.instance;
}
/**
* Handle authentication state changes
*/
private async handleAuthStateChange(isAuthenticated: boolean): Promise<void> {
if (!isBrowser) return;
if (isAuthenticated) {
// User just logged in
await this.handleLogin();
} else {
// User just logged out
await this.handleLogout();
}
}
/**
* Handle login by syncing user data
*/
public async handleLogin(): Promise<boolean> {
if (!isBrowser) return true;
if (this.isSyncing) {
console.log("Sync already in progress, queueing login sync");
if (this.syncPromise) {
this.syncPromise = this.syncPromise.then(() => this.performLoginSync());
} else {
this.syncPromise = this.performLoginSync();
}
return true;
}
this.syncPromise = this.performLoginSync();
return this.syncPromise.then(
() => Object.keys(this.syncErrors).length === 0,
);
}
/**
* Perform the actual login sync
*/
private async performLoginSync(): Promise<void> {
if (!isBrowser) return;
if (!this.auth.isAuthenticated()) {
console.log("Not authenticated, skipping login sync");
return;
}
this.isSyncing = true;
this.syncErrors = {};
try {
console.log("Starting login sync process...");
// Display sync notification if in browser environment
this.showSyncNotification("Syncing your data...");
// Sync user-specific data first
const userId = this.auth.getUserId();
if (userId) {
// First sync the current user's data
await this.dataSync.syncCollection(
Collections.USERS,
`id = "${userId}"`,
);
// Log the sync operation
console.log("User data synchronized on login");
}
// Sync all collections in parallel with conflict resolution
await Promise.all(
this.collectionsToSync.map(async (collection) => {
try {
await this.dataSync.syncCollection(collection);
console.log(`Successfully synced ${collection}`);
} catch (error) {
console.error(`Error syncing ${collection}:`, error);
this.syncErrors[collection] = error as Error;
}
}),
);
// SECURITY FIX: Purge any event codes that might have been synced
await this.dataSync.purgeEventCodes();
// Verify sync was successful
const syncVerification = await this.verifySyncSuccess();
if (syncVerification.success) {
console.log("Login sync completed successfully");
this.showSyncNotification("Data sync complete!", "success");
} else {
console.warn(
"Login sync completed with issues:",
syncVerification.errors,
);
this.showSyncNotification("Some data could not be synced", "warning");
}
} catch (error) {
console.error("Error during login sync:", error);
this.showSyncNotification("Failed to sync data", "error");
} finally {
this.isSyncing = false;
// Process any queued sync operations
if (this.syncQueue.length > 0) {
const nextSync = this.syncQueue.shift();
if (nextSync === "login") {
this.handleLogin();
} else if (nextSync === "logout") {
this.handleLogout();
}
}
}
}
/**
* Handle logout by clearing user data
*/
public async handleLogout(): Promise<boolean> {
if (!isBrowser) return true;
if (this.isSyncing) {
console.log("Sync already in progress, queueing logout cleanup");
this.syncQueue.push("logout");
return true;
}
this.isSyncing = true;
try {
console.log("Starting logout cleanup process...");
// Ensure any pending changes are synced before logout
await this.syncPendingChanges();
// Clear all data from IndexedDB
await this.dexieService.clearAllData();
console.log("Logout cleanup completed successfully");
return true;
} catch (error) {
console.error("Error during logout cleanup:", error);
return false;
} finally {
this.isSyncing = false;
// Process any queued sync operations
if (this.syncQueue.length > 0) {
const nextSync = this.syncQueue.shift();
if (nextSync === "login") {
this.handleLogin();
} else if (nextSync === "logout") {
this.handleLogout();
}
}
}
}
/**
* Sync any pending changes before logout
*/
private async syncPendingChanges(): Promise<void> {
if (!isBrowser) return;
// This would be implemented if we had offline capabilities
// For now, we just log that we would sync pending changes
console.log("Checking for pending changes to sync before logout...");
// In a real implementation, this would sync any offline changes
}
/**
* Verify that sync was successful by checking data in IndexedDB
*/
private async verifySyncSuccess(): Promise<{
success: boolean;
errors: Record<string, string>;
}> {
if (!isBrowser) return { success: true, errors: {} };
const errors: Record<string, string> = {};
// Check each collection that had errors
for (const [collection, error] of Object.entries(this.syncErrors)) {
errors[collection] = error.message;
}
// Check if user data was synced properly
const userId = this.auth.getUserId();
if (userId) {
try {
const user = await this.dataSync.getItem(
Collections.USERS,
userId,
false,
);
if (!user) {
errors["user_verification"] =
"User data not found in IndexedDB after sync";
}
} catch (error) {
errors["user_verification"] =
`Error verifying user data: ${(error as Error).message}`;
}
}
return {
success: Object.keys(errors).length === 0,
errors,
};
}
/**
* Show a notification to the user about sync status
*/
private showSyncNotification(
message: string,
type: "info" | "success" | "warning" | "error" = "info",
): void {
// Only run in browser environment
if (!isBrowser) return;
// Check if toast function exists (from react-hot-toast or similar)
if (typeof window.toast === "function") {
window.toast(message, { type });
} else {
// Fallback to console
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
/**
* Force a sync of all collections
*/
public async forceSyncAll(): Promise<boolean> {
if (this.isSyncing) {
console.log("Sync already in progress, queueing full sync");
this.syncQueue.push("login"); // Reuse login sync logic
return true;
}
return this.handleLogin();
}
/**
* Check if a sync is currently in progress
*/
public isSyncInProgress(): boolean {
return this.isSyncing;
}
/**
* Get any errors from the last sync operation
*/
public getSyncErrors(): Record<string, Error> {
return { ...this.syncErrors };
}
}
// Add toast type to window for TypeScript
declare global {
interface Window {
toast?: (
message: string,
options?: { type: "info" | "success" | "warning" | "error" },
) => void;
}
}

View file

@ -0,0 +1,775 @@
import { DexieService } from "./DexieService";
import { Get } from "../pocketbase/Get";
import { Update } from "../pocketbase/Update";
import { Authentication } from "../pocketbase/Authentication";
import { Collections, type BaseRecord } from "../../schemas/pocketbase/schema";
import type Dexie from "dexie";
// Check if we're in a browser environment
const isBrowser =
typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
// Interface for tracking offline changes
interface OfflineChange {
id: string;
collection: string;
recordId: string;
operation: "create" | "update" | "delete";
data?: any;
timestamp: number;
synced: boolean;
syncAttempts: number;
}
export class DataSyncService {
private static instance: DataSyncService;
private dexieService: DexieService;
private get: Get;
private update: Update;
private auth: Authentication;
private syncInProgress: Record<string, boolean> = {};
private offlineMode: boolean = false;
private offlineChangesTable: Dexie.Table<OfflineChange, string> | null = null;
private constructor() {
this.dexieService = DexieService.getInstance();
this.get = Get.getInstance();
this.update = Update.getInstance();
this.auth = Authentication.getInstance();
// Initialize offline changes table only in browser
if (isBrowser) {
this.initOfflineChangesTable();
// Check for network status
window.addEventListener("online", this.handleOnline.bind(this));
window.addEventListener("offline", this.handleOffline.bind(this));
this.offlineMode = !navigator.onLine;
}
}
public static getInstance(): DataSyncService {
if (!DataSyncService.instance) {
DataSyncService.instance = new DataSyncService();
}
return DataSyncService.instance;
}
/**
* Initialize the offline changes table
*/
private initOfflineChangesTable(): void {
if (!isBrowser) return;
try {
const db = this.dexieService.getDB();
// Check if the table exists in the schema
if ("offlineChanges" in db) {
this.offlineChangesTable = db.offlineChanges as Dexie.Table<
OfflineChange,
string
>;
} else {
console.warn("Offline changes table not found in schema");
}
} catch (error) {
console.error("Error initializing offline changes table:", error);
}
}
/**
* Handle device coming online
*/
private async handleOnline(): Promise<void> {
if (!isBrowser) return;
console.log("Device is online, syncing pending changes...");
this.offlineMode = false;
await this.syncOfflineChanges();
}
/**
* Handle device going offline
*/
private handleOffline(): void {
if (!isBrowser) return;
console.log("Device is offline, enabling offline mode...");
this.offlineMode = true;
}
/**
* Sync a specific collection from PocketBase to IndexedDB
*/
public async syncCollection<T extends BaseRecord>(
collection: string,
filter: string = "",
sort: string = "-created",
expand: Record<string, any> | string[] | string = {},
): Promise<T[]> {
// Skip in non-browser environments
if (!isBrowser) {
console.log(`Skipping sync for ${collection} in non-browser environment`);
return [];
}
// Prevent multiple syncs of the same collection at the same time
if (this.syncInProgress[collection]) {
console.log(`Sync already in progress for ${collection}`);
return [];
}
this.syncInProgress[collection] = true;
try {
// Check if we're authenticated
if (!this.auth.isAuthenticated()) {
console.log(`Not authenticated, skipping sync for ${collection}`);
return [];
}
// Check if we're offline
if (this.offlineMode) {
console.log(`Device is offline, using cached data for ${collection}`);
const db = this.dexieService.getDB();
const table = this.getTableForCollection(collection);
return table ? ((await table.toArray()) as T[]) : [];
}
console.log(`Syncing ${collection}...`);
// Normalize expand parameter to be an array of strings
let normalizedExpand: string[] | undefined;
if (expand) {
if (typeof expand === "string") {
// If expand is a string, convert it to an array
normalizedExpand = [expand];
} else if (Array.isArray(expand)) {
// If expand is already an array, use it as is
normalizedExpand = expand;
} else if (typeof expand === "object") {
// If expand is an object, extract the keys
normalizedExpand = Object.keys(expand);
}
}
// Get data from PocketBase
const items = await this.get.getAll<T>(collection, filter, sort, {
expand: normalizedExpand,
});
console.log(`Fetched ${items.length} items from ${collection}`);
// Get the database table
const db = this.dexieService.getDB();
const table = this.getTableForCollection(collection);
if (!table) {
console.error(`No table found for collection ${collection}`);
return [];
}
// Get existing items to handle conflicts
const existingItems = await table.toArray();
const existingItemsMap = new Map(
existingItems.map((item) => [item.id, item]),
);
// Handle conflicts and merge changes
const itemsToStore = await Promise.all(
items.map(async (item) => {
const existingItem = existingItemsMap.get(item.id);
// SECURITY FIX: Remove event_code from events before storing in IndexedDB
if (collection === Collections.EVENTS && 'event_code' in item) {
// Keep the event_code but ensure files array is properly handled
if ('files' in item && Array.isArray((item as any).files)) {
// Ensure files array is properly stored
console.log(`Event ${item.id} has ${(item as any).files.length} files`);
} else {
// Initialize empty files array if not present
(item as any).files = [];
}
}
if (existingItem) {
// Check for conflicts (local changes vs server changes)
const resolvedItem = await this.resolveConflict(
collection,
existingItem,
item,
);
return resolvedItem;
}
return item;
}),
);
// Store in IndexedDB
await table.bulkPut(itemsToStore);
// Update last sync timestamp
await this.dexieService.updateLastSync(collection);
return itemsToStore as T[];
} catch (error) {
console.error(`Error syncing ${collection}:`, error);
throw error;
} finally {
this.syncInProgress[collection] = false;
}
}
/**
* Resolve conflicts between local and server data
*/
private async resolveConflict<T extends BaseRecord>(
collection: string,
localItem: T,
serverItem: T,
): Promise<T> {
// For events, ensure we handle the files field properly
if (collection === Collections.EVENTS) {
// Ensure files array is properly handled
if ('files' in serverItem && Array.isArray((serverItem as any).files)) {
console.log(`Server event ${serverItem.id} has ${(serverItem as any).files.length} files`);
} else {
// Initialize empty files array if not present
(serverItem as any).files = [];
}
// If local item has files but server doesn't, preserve local files
if ('files' in localItem && Array.isArray((localItem as any).files) &&
(localItem as any).files.length > 0 &&
(!('files' in serverItem) || !(serverItem as any).files.length)) {
console.log(`Preserving local files for event ${localItem.id}`);
(serverItem as any).files = (localItem as any).files;
}
}
// Check if there are pending offline changes for this item
const pendingChanges = await this.getPendingChangesForRecord(
collection,
localItem.id,
);
if (pendingChanges.length > 0) {
console.log(
`Found ${pendingChanges.length} pending changes for ${collection}:${localItem.id}`,
);
// Server-wins strategy by default, but preserve local changes that haven't been synced
const mergedItem = { ...serverItem };
// Apply pending changes on top of server data
for (const change of pendingChanges) {
if (change.operation === "update" && change.data) {
// Apply each field change individually
Object.entries(change.data).forEach(([key, value]) => {
// Special handling for files array
if (key === 'files' && Array.isArray(value)) {
// Merge files arrays, removing duplicates
const existingFiles = Array.isArray((mergedItem as any)[key]) ? (mergedItem as any)[key] : [];
const newFiles = value as string[];
(mergedItem as any)[key] = [...new Set([...existingFiles, ...newFiles])];
console.log(`Merged files for ${collection}:${localItem.id}`, (mergedItem as any)[key]);
} else {
(mergedItem as any)[key] = value;
}
});
}
}
return mergedItem;
}
// No pending changes, use server data
return serverItem;
}
/**
* Get pending changes for a specific record
*/
private async getPendingChangesForRecord(
collection: string,
recordId: string,
): Promise<OfflineChange[]> {
if (!this.offlineChangesTable) return [];
try {
return await this.offlineChangesTable
.where("collection")
.equals(collection)
.and((item) => item.recordId === recordId && !item.synced)
.toArray();
} catch (error) {
console.error(
`Error getting pending changes for ${collection}:${recordId}:`,
error,
);
return [];
}
}
/**
* Sync all pending offline changes
*/
public async syncOfflineChanges(): Promise<boolean> {
if (!this.offlineChangesTable || this.offlineMode) return false;
try {
// Get all unsynced changes
const pendingChanges = await this.offlineChangesTable
.where("synced")
.equals(0) // Use 0 instead of false for indexable type
.toArray();
if (pendingChanges.length === 0) {
console.log("No pending offline changes to sync");
return true;
}
console.log(`Syncing ${pendingChanges.length} offline changes...`);
// Group changes by collection for more efficient processing
const changesByCollection = pendingChanges.reduce(
(groups, change) => {
const key = change.collection;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(change);
return groups;
},
{} as Record<string, OfflineChange[]>,
);
// Process each collection's changes
for (const [collection, changes] of Object.entries(changesByCollection)) {
// First sync the collection to get latest data
await this.syncCollection(collection);
// Then apply each change
for (const change of changes) {
try {
if (change.operation === "update" && change.data) {
await this.update.updateFields(
collection,
change.recordId,
change.data,
);
// Mark as synced
await this.offlineChangesTable.update(change.id, {
synced: true,
syncAttempts: change.syncAttempts + 1,
});
}
// Add support for create and delete operations as needed
} catch (error) {
console.error(`Error syncing change ${change.id}:`, error);
// Increment sync attempts
await this.offlineChangesTable.update(change.id, {
syncAttempts: change.syncAttempts + 1,
});
}
}
// Sync again to ensure we have the latest data
await this.syncCollection(collection);
}
return true;
} catch (error) {
console.error("Error syncing offline changes:", error);
return false;
}
}
/**
* Record an offline change
*/
public async recordOfflineChange(
collection: string,
recordId: string,
operation: "create" | "update" | "delete",
data?: any,
): Promise<string | null> {
if (!this.offlineChangesTable) return null;
try {
const change: Omit<OfflineChange, "id"> = {
collection,
recordId,
operation,
data,
timestamp: Date.now(),
synced: false,
syncAttempts: 0,
};
const id = await this.offlineChangesTable.add(change as OfflineChange);
console.log(
`Recorded offline change: ${operation} on ${collection}:${recordId}`,
);
// Try to sync immediately if we're online
if (!this.offlineMode) {
this.syncOfflineChanges().catch((err) => {
console.error("Error syncing after recording change:", err);
});
}
return id;
} catch (error) {
console.error(
`Error recording offline change for ${collection}:${recordId}:`,
error,
);
return null;
}
}
/**
* Get data from IndexedDB, syncing from PocketBase if needed
*/
public async getData<T extends BaseRecord>(
collection: string,
forceSync: boolean = false,
filter: string = "",
sort: string = "-created",
expand: Record<string, any> | string[] | string = {},
): Promise<T[]> {
const db = this.dexieService.getDB();
const table = this.getTableForCollection(collection);
if (!table) {
console.error(`No table found for collection ${collection}`);
return [];
}
// Check if we need to sync
const lastSync = await this.dexieService.getLastSync(collection);
const now = Date.now();
const syncThreshold = 5 * 60 * 1000; // 5 minutes
if (!this.offlineMode && (forceSync || now - lastSync > syncThreshold)) {
try {
await this.syncCollection<T>(collection, filter, sort, expand);
} catch (error) {
console.error(`Error syncing ${collection}, using cached data:`, error);
}
}
// Get data from IndexedDB
let data = await table.toArray();
// Apply filter if provided
if (filter) {
// This is a simple implementation - in a real app, you'd want to parse the filter string
// and apply it properly. This is just a basic example.
data = data.filter((item: any) => {
// Split filter by logical operators
const conditions = filter.split(" && ");
return conditions.every((condition) => {
// Parse condition (very basic implementation)
if (condition.includes("=")) {
const [field, value] = condition.split("=");
const cleanValue = value.replace(/"/g, "");
return item[field] === cleanValue;
}
return true;
});
});
}
// Apply sort if provided
if (sort) {
const isDesc = sort.startsWith("-");
const field = isDesc ? sort.substring(1) : sort;
data.sort((a: any, b: any) => {
if (a[field] < b[field]) return isDesc ? 1 : -1;
if (a[field] > b[field]) return isDesc ? -1 : 1;
return 0;
});
}
// SECURITY FIX: Remove event_code from events before returning them
if (collection === Collections.EVENTS) {
data = data.map((item: any) => {
if ('event_code' in item) {
const { event_code, ...rest } = item;
return rest;
}
return item;
});
}
return data as T[];
}
/**
* Get a single item by ID
*/
public async getItem<T extends BaseRecord>(
collection: string,
id: string,
forceSync: boolean = false,
): Promise<T | undefined> {
const db = this.dexieService.getDB();
const table = this.getTableForCollection(collection);
if (!table) {
console.error(`No table found for collection ${collection}`);
return undefined;
}
// Try to get from IndexedDB first
let item = (await table.get(id)) as T | undefined;
// If not found or force sync, try to get from PocketBase
if ((!item || forceSync) && !this.offlineMode) {
try {
const pbItem = await this.get.getOne<T>(collection, id);
if (pbItem) {
// For events, ensure we handle the files field properly
if (collection === Collections.EVENTS) {
// Ensure files array is properly handled
if (!('files' in pbItem) || !Array.isArray((pbItem as any).files)) {
(pbItem as any).files = [];
}
// If we already have a local item with files, preserve them if server has none
if (item && 'files' in item && Array.isArray((item as any).files) &&
(item as any).files.length > 0 && !(pbItem as any).files.length) {
console.log(`Preserving local files for event ${id}`);
(pbItem as any).files = (item as any).files;
}
await table.put(pbItem);
item = pbItem;
} else {
await table.put(pbItem);
item = pbItem;
}
}
} catch (error) {
console.error(`Error fetching ${collection} item ${id}:`, error);
}
}
return item;
}
/**
* Update an item and handle offline changes
*/
public async updateItem<T extends BaseRecord>(
collection: string,
id: string,
data: Partial<T>,
): Promise<T | undefined> {
const table = this.getTableForCollection(collection);
if (!table) {
console.error(`No table found for collection ${collection}`);
return undefined;
}
// Get the current item
const currentItem = (await table.get(id)) as T | undefined;
if (!currentItem) {
console.error(`Item ${id} not found in ${collection}`);
return undefined;
}
// Special handling for files field in events
if (collection === Collections.EVENTS && 'files' in data) {
console.log(`Updating files for event ${id}`, (data as any).files);
// Ensure files is an array
if (!Array.isArray((data as any).files)) {
(data as any).files = [];
}
// If we're updating files, make sure we're not losing any
if ('files' in currentItem && Array.isArray((currentItem as any).files)) {
// Merge files arrays, removing duplicates
const existingFiles = (currentItem as any).files as string[];
const newFiles = (data as any).files as string[];
(data as any).files = [...new Set([...existingFiles, ...newFiles])];
console.log(`Merged files for event ${id}`, (data as any).files);
}
}
// Update the item in IndexedDB
const updatedItem = {
...currentItem,
...data,
updated: new Date().toISOString(),
};
await table.put(updatedItem);
// If offline, record the change for later sync
if (this.offlineMode) {
await this.recordOfflineChange(collection, id, "update", data);
return updatedItem;
}
// If online, update in PocketBase
try {
const result = await this.update.updateFields(collection, id, data);
return result as T;
} catch (error) {
console.error(`Error updating ${collection} item ${id}:`, error);
// Record as offline change to retry later
await this.recordOfflineChange(collection, id, "update", data);
return updatedItem;
}
}
/**
* Clear all cached data
*/
public async clearCache(): Promise<void> {
await this.dexieService.clearAllData();
}
/**
* Check if device is in offline mode
*/
public isOffline(): boolean {
return this.offlineMode;
}
/**
* Get the appropriate Dexie table for a collection
*/
private getTableForCollection(
collection: string,
): Dexie.Table<any, string> | null {
const db = this.dexieService.getDB();
switch (collection) {
case Collections.USERS:
return db.users;
case Collections.EVENTS:
return db.events;
case Collections.EVENT_ATTENDEES:
return db.eventAttendees;
case Collections.EVENT_REQUESTS:
return db.eventRequests;
case Collections.LOGS:
return db.logs;
case Collections.OFFICERS:
return db.officers;
case Collections.REIMBURSEMENTS:
return db.reimbursements;
case Collections.RECEIPTS:
return db.receipts;
case Collections.SPONSORS:
return db.sponsors;
default:
console.error(`Unknown collection: ${collection}`);
return null;
}
}
/**
* Store an event code in local storage for offline check-in
* This is used when a user scans a QR code but is offline
* @param eventCode The event code to store
*/
public async storeEventCode(eventCode: string): Promise<void> {
if (!isBrowser) return;
try {
// Store in localStorage instead of IndexedDB for security
localStorage.setItem('pending_event_code', eventCode);
localStorage.setItem('pending_event_code_timestamp', Date.now().toString());
console.log('Event code stored for offline check-in');
} catch (error) {
console.error('Error storing event code:', error);
}
}
/**
* Clear the stored event code from local storage
*/
public async clearEventCode(): Promise<void> {
if (!isBrowser) return;
try {
localStorage.removeItem('pending_event_code');
localStorage.removeItem('pending_event_code_timestamp');
console.log('Event code cleared');
} catch (error) {
console.error('Error clearing event code:', error);
}
}
/**
* Get the stored event code from local storage
* @returns The stored event code, or null if none exists
*/
public async getStoredEventCode(): Promise<{ code: string; timestamp: number } | null> {
if (!isBrowser) return null;
try {
const code = localStorage.getItem('pending_event_code');
const timestamp = localStorage.getItem('pending_event_code_timestamp');
if (!code || !timestamp) return null;
return {
code,
timestamp: parseInt(timestamp)
};
} catch (error) {
console.error('Error getting stored event code:', error);
return null;
}
}
/**
* Purge event_code fields from events in IndexedDB for security
* This should be called on login to ensure no event codes are stored
*/
public async purgeEventCodes(): Promise<void> {
if (!isBrowser) return;
try {
const db = this.dexieService.getDB();
const table = this.getTableForCollection(Collections.EVENTS);
if (!table) {
console.error('Events table not found');
return;
}
// Get all events
const events = await table.toArray();
// Remove event_code from each event
const updatedEvents = events.map(event => {
if ('event_code' in event) {
const { event_code, ...rest } = event;
return rest;
}
return event;
});
// Clear the table and add the updated events
await table.clear();
await table.bulkAdd(updatedEvents);
console.log('Successfully purged event codes from IndexedDB');
} catch (error) {
console.error('Error purging event codes:', error);
}
}
}

View file

@ -0,0 +1,198 @@
import Dexie from "dexie";
import type {
User,
Event,
EventRequest,
Log,
Officer,
Reimbursement,
Receipt,
Sponsor,
EventAttendee,
} from "../../schemas/pocketbase/schema";
// Check if we're in a browser environment
const isBrowser =
typeof window !== "undefined" && typeof window.indexedDB !== "undefined";
// Interface for tracking offline changes
interface OfflineChange {
id: string;
collection: string;
recordId: string;
operation: "create" | "update" | "delete";
data?: any;
timestamp: number;
synced: boolean;
syncAttempts: number;
}
export class DashboardDatabase extends Dexie {
users!: Dexie.Table<User, string>;
events!: Dexie.Table<Event, string>;
eventAttendees!: Dexie.Table<EventAttendee, string>;
eventRequests!: Dexie.Table<EventRequest, string>;
logs!: Dexie.Table<Log, string>;
officers!: Dexie.Table<Officer, string>;
reimbursements!: Dexie.Table<Reimbursement, string>;
receipts!: Dexie.Table<Receipt, string>;
sponsors!: Dexie.Table<Sponsor, string>;
offlineChanges!: Dexie.Table<OfflineChange, string>;
// Store last sync timestamps
syncInfo!: Dexie.Table<
{ id: string; collection: string; lastSync: number },
string
>;
constructor() {
super("IEEEDashboardDB");
this.version(1).stores({
users: "id, email, name",
events: "id, event_name, event_code, start_date, end_date, published",
eventRequests: "id, name, status, requested_user, created, updated",
logs: "id, user, type, created",
officers: "id, user, role, type",
reimbursements: "id, title, status, submitted_by, created",
receipts: "id, created_by, date",
sponsors: "id, user, company",
syncInfo: "id, collection, lastSync",
});
// Add version 2 with offlineChanges table
this.version(2).stores({
offlineChanges:
"id, collection, recordId, operation, timestamp, synced, syncAttempts",
});
// Add version 3 with eventAttendees table and updated events table (no attendees field)
this.version(3).stores({
events: "id, event_name, event_code, start_date, end_date, published",
eventAttendees: "id, user, event, time_checked_in",
});
// Add version 4 with files field in events table
this.version(4).stores({
events: "id, event_name, event_code, start_date, end_date, published, files",
});
}
// Initialize the database with default values
async initialize() {
const collections = [
"users",
"events",
"event_attendees",
"event_request",
"logs",
"officers",
"reimbursement",
"receipts",
"sponsors",
];
for (const collection of collections) {
const exists = await this.syncInfo.get(collection);
if (!exists) {
await this.syncInfo.put({
id: collection,
collection,
lastSync: 0,
});
}
}
}
}
// Mock database for server-side rendering
class MockDashboardDatabase {
// Implement empty methods that won't fail during SSR
async initialize() {
// Do nothing
}
}
// Singleton pattern
export class DexieService {
private static instance: DexieService;
private db: DashboardDatabase | MockDashboardDatabase;
private constructor() {
if (isBrowser) {
// Only initialize Dexie in browser environments
this.db = new DashboardDatabase();
this.db.initialize();
} else {
// Use a mock database in non-browser environments
console.log("Running in Node.js environment, using mock database");
this.db = new MockDashboardDatabase() as any;
}
}
public static getInstance(): DexieService {
if (!DexieService.instance) {
DexieService.instance = new DexieService();
}
return DexieService.instance;
}
// Get the database instance
public getDB(): DashboardDatabase {
if (!isBrowser) {
console.warn(
"Attempting to access IndexedDB in a non-browser environment",
);
}
return this.db as DashboardDatabase;
}
// Update the last sync timestamp for a collection
public async updateLastSync(collection: string): Promise<void> {
if (!isBrowser) return;
await (this.db as DashboardDatabase).syncInfo.update(collection, {
lastSync: Date.now(),
});
}
// Get the last sync timestamp for a collection
public async getLastSync(collection: string): Promise<number> {
if (!isBrowser) return 0;
const info = await (this.db as DashboardDatabase).syncInfo.get(collection);
return info?.lastSync || 0;
}
// Clear all data (useful for logout)
public async clearAllData(): Promise<void> {
if (!isBrowser) return;
const db = this.db as DashboardDatabase;
await db.users.clear();
await db.events.clear();
await db.eventAttendees.clear();
await db.eventRequests.clear();
await db.logs.clear();
await db.officers.clear();
await db.reimbursements.clear();
await db.receipts.clear();
await db.sponsors.clear();
await db.offlineChanges.clear();
// Reset sync timestamps
const collections = [
"users",
"events",
"event_attendees",
"event_request",
"logs",
"officers",
"reimbursement",
"receipts",
"sponsors",
];
for (const collection of collections) {
await db.syncInfo.update(collection, { lastSync: 0 });
}
}
}

View file

@ -0,0 +1,35 @@
import { Authentication } from '../pocketbase/Authentication';
/**
* Initialize authentication synchronization
* This function should be called when the application starts
* to ensure proper data synchronization during authentication flows
*/
export async function initAuthSync(): Promise<void> {
try {
// Get Authentication instance
const auth = Authentication.getInstance();
// This will trigger the lazy loading of AuthSyncService
// through the onAuthStateChange mechanism
auth.onAuthStateChange(() => {
console.log('Auth sync initialized and listening for auth state changes');
});
console.log('Auth sync initialization complete');
} catch (error) {
console.error('Failed to initialize auth sync:', error);
}
}
// Export a function to manually trigger a full sync
export async function forceFullSync(): Promise<boolean> {
try {
const { AuthSyncService } = await import('./AuthSyncService');
const authSync = AuthSyncService.getInstance();
return await authSync.forceSyncAll();
} catch (error) {
console.error('Failed to force full sync:', error);
return false;
}
}

View file

@ -0,0 +1,195 @@
import PocketBase from "pocketbase";
import yaml from "js-yaml";
import configYaml from "../../config/pocketbaseConfig.yml?raw";
// Configuration type definitions
interface Config {
api: {
baseUrl: string;
oauth2: {
redirectPath: string;
providerName: string;
};
};
}
// Parse YAML configuration
const config = yaml.load(configYaml) as Config;
export class Authentication {
private pb: PocketBase;
private static instance: Authentication;
private authChangeCallbacks: ((isValid: boolean) => void)[] = [];
private isUpdating: boolean = false;
private authSyncServiceInitialized: boolean = false;
private constructor() {
// Use the baseUrl from the config file
this.pb = new PocketBase(config.api.baseUrl);
// Configure PocketBase client
this.pb.autoCancellation(false); // Disable auto-cancellation globally
// Listen for auth state changes
this.pb.authStore.onChange(() => {
if (!this.isUpdating) {
this.notifyAuthChange();
}
});
}
/**
* Get the singleton instance of Authentication
*/
public static getInstance(): Authentication {
if (!Authentication.instance) {
Authentication.instance = new Authentication();
}
return Authentication.instance;
}
/**
* Get the PocketBase instance
*/
public getPocketBase(): PocketBase {
return this.pb;
}
/**
* Handle user login through OAuth2
*/
public async login(): Promise<void> {
try {
const authMethods = await this.pb.collection("users").listAuthMethods();
const oidcProvider = authMethods.oauth2?.providers?.find(
(p: { name: string }) => p.name === config.api.oauth2.providerName,
);
if (!oidcProvider) {
throw new Error("OIDC provider not found");
}
localStorage.setItem("provider", JSON.stringify(oidcProvider));
const redirectUrl =
window.location.origin + config.api.oauth2.redirectPath;
const authUrl = oidcProvider.authURL + encodeURIComponent(redirectUrl);
window.location.href = authUrl;
} catch (err) {
console.error("Authentication error:", err);
throw err;
}
}
/**
* Handle user logout
*/
public async logout(): Promise<void> {
try {
// Initialize AuthSyncService if needed (lazy loading)
await this.initAuthSyncService();
// Get AuthSyncService instance
const { AuthSyncService } = await import('../database/AuthSyncService');
const authSync = AuthSyncService.getInstance();
// Handle data cleanup before actual logout
await authSync.handleLogout();
// Clear auth store
this.pb.authStore.clear();
console.log('Logout completed successfully with data cleanup');
} catch (error) {
console.error('Error during logout:', error);
// Fallback to basic logout if sync fails
this.pb.authStore.clear();
}
}
/**
* Check if user is currently authenticated
*/
public isAuthenticated(): boolean {
return this.pb.authStore.isValid;
}
/**
* Get current user model
*/
public getCurrentUser(): any {
return this.pb.authStore.model;
}
/**
* Get current user ID
*/
public getUserId(): string | null {
return this.pb.authStore.model?.id || null;
}
/**
* Subscribe to auth state changes
* @param callback Function to call when auth state changes
*/
public onAuthStateChange(callback: (isValid: boolean) => void): void {
this.authChangeCallbacks.push(callback);
// Initialize AuthSyncService when first callback is registered
if (!this.authSyncServiceInitialized && this.authChangeCallbacks.length === 1) {
this.initAuthSyncService();
}
}
/**
* Remove auth state change subscription
* @param callback Function to remove from subscribers
*/
public offAuthStateChange(callback: (isValid: boolean) => void): void {
this.authChangeCallbacks = this.authChangeCallbacks.filter(
(cb) => cb !== callback,
);
}
/**
* Set updating state to prevent auth change notifications during updates
*/
public setUpdating(updating: boolean): void {
this.isUpdating = updating;
}
/**
* Notify all subscribers of auth state change
*/
private notifyAuthChange(): void {
const isValid = this.pb.authStore.isValid;
this.authChangeCallbacks.forEach((callback) => callback(isValid));
}
/**
* Initialize the AuthSyncService (lazy loading)
*/
private async initAuthSyncService(): Promise<void> {
if (this.authSyncServiceInitialized) return;
try {
// Dynamically import AuthSyncService to avoid circular dependencies
const { AuthSyncService } = await import('../database/AuthSyncService');
// Initialize the service
AuthSyncService.getInstance();
this.authSyncServiceInitialized = true;
console.log('AuthSyncService initialized successfully');
// If user is already authenticated, trigger initial sync
if (this.isAuthenticated()) {
const authSync = AuthSyncService.getInstance();
authSync.handleLogin().catch(err => {
console.error('Error during initial data sync:', err);
});
}
} catch (error) {
console.error('Failed to initialize AuthSyncService:', error);
}
}
}

View file

@ -0,0 +1,756 @@
import { Authentication } from "./Authentication";
export class FileManager {
private auth: Authentication;
private static instance: FileManager;
private static UNSUPPORTED_EXTENSIONS = ['afdesign', 'psd', 'ai', 'sketch'];
private constructor() {
this.auth = Authentication.getInstance();
}
/**
* Get the singleton instance of FileManager
*/
public static getInstance(): FileManager {
if (!FileManager.instance) {
FileManager.instance = new FileManager();
}
return FileManager.instance;
}
/**
* Validates if a file type is supported
* @param file The file to validate
* @returns Object with validation result and reason if invalid
*/
public validateFileType(file: File): { valid: boolean; reason?: string } {
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (fileExtension && FileManager.UNSUPPORTED_EXTENSIONS.includes(fileExtension)) {
return {
valid: false,
reason: `File type .${fileExtension} is not supported. Please convert to PDF or image format.`
};
}
return { valid: true };
}
/**
* Upload a single file to a record
* @param collectionName The name of the collection
* @param recordId The ID of the record to attach the file to
* @param field The field name for the file
* @param file The file to upload
* @param append Whether to append the file to existing files (default: false)
* @returns The updated record
*/
public async uploadFile<T = any>(
collectionName: string,
recordId: string,
field: string,
file: File,
append: boolean = false
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to upload files");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
// Validate file size
const maxSize = 200 * 1024 * 1024; // 200MB
if (file.size > maxSize) {
throw new Error(`File size ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds 200MB limit`);
}
// Check for potentially problematic file types
const fileExtension = file.name.split('.').pop()?.toLowerCase();
// Validate file type
const validation = this.validateFileType(file);
if (!validation.valid) {
throw new Error(validation.reason);
}
// Log upload attempt
console.log('Attempting file upload:', {
name: file.name,
size: file.size,
type: file.type,
extension: fileExtension,
collection: collectionName,
recordId: recordId,
field: field,
append: append
});
// Create FormData for the upload
const formData = new FormData();
// Use the + prefix for the field name if append is true
const fieldName = append ? `${field}+` : field;
// Get existing record to preserve existing files
let existingRecord: any = null;
let existingFiles: string[] = [];
try {
if (recordId) {
existingRecord = await pb.collection(collectionName).getOne(recordId);
existingFiles = existingRecord[field] || [];
}
} catch (error) {
console.warn('Could not fetch existing record:', error);
}
// Check if the file already exists
const fileExists = existingFiles.some(existingFile =>
existingFile.toLowerCase() === file.name.toLowerCase()
);
if (fileExists) {
console.warn(`File with name ${file.name} already exists. Renaming to avoid conflicts.`);
const timestamp = new Date().getTime();
const nameParts = file.name.split('.');
const extension = nameParts.pop();
const baseName = nameParts.join('.');
const newFileName = `${baseName}_${timestamp}.${extension}`;
// Create a new file with the modified name
const newFile = new File([file], newFileName, { type: file.type });
formData.append(fieldName, newFile);
} else {
formData.append(fieldName, file);
}
try {
const result = await pb.collection(collectionName).update<T>(recordId, formData);
console.log('Upload successful:', {
result,
fileInfo: {
name: file.name,
size: file.size,
type: file.type
},
collection: collectionName,
recordId: recordId
});
// Verify the file was actually added to the record
try {
const updatedRecord = await pb.collection(collectionName).getOne(recordId);
console.log('Updated record files:', {
files: updatedRecord.files,
recordId: recordId
});
} catch (verifyError) {
console.warn('Could not verify file upload:', verifyError);
}
return result;
} catch (pbError: any) {
// Log detailed PocketBase error
console.error('PocketBase upload error:', {
status: pbError?.status,
response: pbError?.response,
data: pbError?.data,
message: pbError?.message
});
// More specific error message based on file type
if (fileExtension && FileManager.UNSUPPORTED_EXTENSIONS.includes(fileExtension)) {
throw new Error(`Upload failed: File type .${fileExtension} is not supported. Please convert to PDF or image format.`);
}
throw new Error(`Upload failed: ${pbError?.message || 'Unknown PocketBase error'}`);
}
} catch (err) {
console.error(`Failed to upload file to ${collectionName}:`, {
error: err,
fileInfo: {
name: file.name,
size: file.size,
type: file.type
},
auth: {
isAuthenticated: this.auth.isAuthenticated(),
userId: this.auth.getUserId()
}
});
if (err instanceof Error) {
throw err;
}
throw new Error(`Upload failed: ${err}`);
} finally {
this.auth.setUpdating(false);
}
}
/**
* Upload multiple files to a record with chunked upload support
* @param collectionName The name of the collection
* @param recordId The ID of the record to attach the files to
* @param field The field name for the files
* @param files Array of files to upload
* @returns The updated record
*/
public async uploadFiles<T = any>(
collectionName: string,
recordId: string,
field: string,
files: File[],
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to upload files");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file limit
const MAX_BATCH_SIZE = 25 * 1024 * 1024; // 25MB per batch
// Validate file types and sizes first
for (const file of files) {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
throw new Error(
`File ${file.name} is too large. Maximum size is 50MB.`,
);
}
// Validate file type
const validation = this.validateFileType(file);
if (!validation.valid) {
throw new Error(`File ${file.name}: ${validation.reason}`);
}
}
// Get existing record if updating
let existingFiles: string[] = [];
if (recordId) {
try {
const record = await pb
.collection(collectionName)
.getOne<T>(recordId);
existingFiles = (record as any)[field] || [];
} catch (error) {
console.warn("Failed to fetch existing record:", error);
}
}
// Process files in batches
let currentBatchSize = 0;
let currentBatch: File[] = [];
let allProcessedFiles: File[] = [];
// Process each file
for (const file of files) {
let processedFile = file;
try {
// Try to compress image files if needed
if (file.type.startsWith("image/")) {
processedFile = await this.compressImageIfNeeded(file, 50); // 50MB max size
}
} catch (error) {
console.warn(`Failed to process file ${file.name}:`, error);
processedFile = file; // Use original file if processing fails
}
// Check if adding this file would exceed batch size
if (currentBatchSize + processedFile.size > MAX_BATCH_SIZE) {
// Upload current batch
if (currentBatch.length > 0) {
await this.uploadBatch(
collectionName,
recordId,
field,
currentBatch,
);
allProcessedFiles.push(...currentBatch);
}
// Reset batch
currentBatch = [processedFile];
currentBatchSize = processedFile.size;
} else {
// Add to current batch
currentBatch.push(processedFile);
currentBatchSize += processedFile.size;
}
}
// Upload any remaining files
if (currentBatch.length > 0) {
await this.uploadBatch(collectionName, recordId, field, currentBatch);
allProcessedFiles.push(...currentBatch);
}
// Get the final record state
const finalRecord = await pb
.collection(collectionName)
.getOne<T>(recordId);
return finalRecord;
} catch (err) {
console.error(`Failed to upload files to ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Upload a batch of files
* @private
*/
private async uploadBatch<T = any>(
collectionName: string,
recordId: string,
field: string,
files: File[],
): Promise<void> {
const pb = this.auth.getPocketBase();
const formData = new FormData();
// Get existing files to check for duplicates
let existingFiles: string[] = [];
try {
const record = await pb.collection(collectionName).getOne(recordId);
existingFiles = record[field] || [];
} catch (error) {
console.warn("Failed to fetch existing record for duplicate check:", error);
}
// Add new files, renaming duplicates if needed
for (const file of files) {
let fileToUpload = file;
// Check if filename already exists
if (Array.isArray(existingFiles) && existingFiles.includes(file.name)) {
const timestamp = new Date().getTime();
const nameParts = file.name.split('.');
const extension = nameParts.pop();
const baseName = nameParts.join('.');
const newFileName = `${baseName}_${timestamp}.${extension}`;
// Create a new file with the modified name
fileToUpload = new File([file], newFileName, { type: file.type });
console.log(`Renamed duplicate file from ${file.name} to ${newFileName}`);
}
formData.append(field, fileToUpload);
}
// Tell PocketBase to keep existing files
if (existingFiles.length > 0) {
formData.append(`${field}@`, ''); // This tells PocketBase to keep existing files
}
try {
await pb.collection(collectionName).update(recordId, formData);
} catch (error: any) {
if (error.status === 413) {
throw new Error(
`Upload failed: Batch size too large. Please try uploading smaller files.`,
);
}
throw error;
}
}
/**
* Append multiple files to a record without overriding existing ones
* @param collectionName The name of the collection
* @param recordId The ID of the record to attach the files to
* @param field The field name for the files
* @param files Array of files to upload
* @returns The updated record
*/
public async appendFiles<T = any>(
collectionName: string,
recordId: string,
field: string,
files: File[],
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to upload files");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
// First, get the current record to check existing files
const record = await pb.collection(collectionName).getOne<T>(recordId);
// Get existing files from the record
const existingFiles = (record as any)[field] || [];
const existingFilenames = new Set(existingFiles);
// Create FormData for the new files only
const formData = new FormData();
// Tell PocketBase to keep existing files
formData.append(`${field}@`, '');
// Append new files, renaming if needed to avoid duplicates
for (const file of files) {
let fileToUpload = file;
// Check if filename already exists
if (existingFilenames.has(file.name)) {
const timestamp = new Date().getTime();
const nameParts = file.name.split('.');
const extension = nameParts.pop();
const baseName = nameParts.join('.');
const newFileName = `${baseName}_${timestamp}.${extension}`;
// Create a new file with the modified name
fileToUpload = new File([file], newFileName, { type: file.type });
console.log(`Renamed duplicate file from ${file.name} to ${newFileName}`);
}
formData.append(field, fileToUpload);
}
const result = await pb
.collection(collectionName)
.update<T>(recordId, formData);
return result;
} catch (err) {
console.error(`Failed to append files to ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Get the URL for a file
* @param collectionName The name of the collection
* @param recordId The ID of the record containing the file
* @param filename The name of the file
* @returns The URL to access the file
*/
public getFileUrl(
collectionName: string,
recordId: string,
filename: string,
): string {
const pb = this.auth.getPocketBase();
return pb.files.getURL(
{ id: recordId, collectionId: collectionName },
filename,
);
}
/**
* Delete a file from a record
* @param collectionName The name of the collection
* @param recordId The ID of the record containing the file
* @param field The field name of the file to delete
* @returns The updated record
*/
public async deleteFile<T = any>(
collectionName: string,
recordId: string,
field: string,
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to delete files");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const data = { [field]: null };
const result = await pb
.collection(collectionName)
.update<T>(recordId, data);
return result;
} catch (err) {
console.error(`Failed to delete file from ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Download a file
* @param collectionName The name of the collection
* @param recordId The ID of the record containing the file
* @param filename The name of the file
* @returns The file blob
*/
public async downloadFile(
collectionName: string,
recordId: string,
filename: string,
): Promise<Blob> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to download files");
}
try {
this.auth.setUpdating(true);
const url = this.getFileUrl(collectionName, recordId, filename);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.blob();
return result;
} catch (err) {
console.error(`Failed to download file from ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Get multiple files from a record
* @param collectionName The name of the collection
* @param recordId The ID of the record containing the files
* @param field The field name containing the files
* @returns Array of file URLs
*/
public async getFiles(
collectionName: string,
recordId: string,
field: string,
): Promise<string[]> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to get files");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
// Get the record to retrieve the filenames
const record = await pb.collection(collectionName).getOne(recordId);
// Get the filenames from the specified field
const filenames = record[field] || [];
// Convert filenames to URLs
const fileUrls = filenames.map((filename: string) =>
this.getFileUrl(collectionName, recordId, filename),
);
return fileUrls;
} catch (err) {
console.error(`Failed to get files from ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Compress an image file if it's too large
* @param file The image file to compress
* @param maxSizeInMB Maximum size in MB
* @returns Promise<File> The compressed file
*/
public async compressImageIfNeeded(
file: File,
maxSizeInMB: number = 50,
): Promise<File> {
if (!file.type.startsWith("image/")) {
return file;
}
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
if (file.size <= maxSizeInBytes) {
return file;
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement("canvas");
let width = img.width;
let height = img.height;
// Calculate new dimensions while maintaining aspect ratio
const maxDimension = 3840; // Higher quality for larger files
if (width > height && width > maxDimension) {
height *= maxDimension / width;
width = maxDimension;
} else if (height > maxDimension) {
width *= maxDimension / height;
height = maxDimension;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0, width, height);
// Convert to blob with higher quality for larger files
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to compress image"));
return;
}
resolve(
new File([blob], file.name, {
type: "image/jpeg",
lastModified: Date.now(),
}),
);
},
"image/jpeg",
0.85, // Higher quality setting for larger files
);
};
img.onerror = () => {
reject(new Error("Failed to load image for compression"));
};
};
reader.onerror = () => {
reject(new Error("Failed to read file for compression"));
};
});
}
/**
* Get a file token for accessing protected files
* @returns Promise<string> The file token
*/
public async getFileToken(): Promise<string> {
// Check authentication status
if (!this.auth.isAuthenticated()) {
console.warn("User is not authenticated when trying to get file token");
// Try to refresh the auth if possible
try {
const pb = this.auth.getPocketBase();
if (pb.authStore.isValid) {
console.log(
"Auth store is valid, but auth check failed. Trying to refresh token.",
);
await pb.collection("users").authRefresh();
console.log("Auth refreshed successfully");
} else {
throw new Error("User must be authenticated to get a file token");
}
} catch (refreshError) {
console.error("Failed to refresh authentication:", refreshError);
throw new Error("User must be authenticated to get a file token");
}
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
// Log auth status
console.log("Auth status before getting token:", {
isValid: pb.authStore.isValid,
token: pb.authStore.token
? pb.authStore.token.substring(0, 10) + "..."
: "none",
model: pb.authStore.model ? pb.authStore.model.id : "none",
});
const result = await pb.files.getToken();
console.log("Got file token:", result.substring(0, 10) + "...");
return result;
} catch (err) {
console.error("Failed to get file token:", err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Get a file URL with an optional token for protected files
* @param collectionName The name of the collection
* @param recordId The ID of the record containing the file
* @param filename The name of the file
* @param useToken Whether to include a token for protected files
* @returns Promise<string> The file URL with token if requested
*/
public async getFileUrlWithToken(
collectionName: string,
recordId: string,
filename: string,
useToken: boolean = false,
): Promise<string> {
const pb = this.auth.getPocketBase();
// Check if filename is empty
if (!filename) {
console.error(
`Empty filename provided for ${collectionName}/${recordId}`,
);
return "";
}
// Check if user is authenticated
if (!this.auth.isAuthenticated()) {
console.warn("User is not authenticated when trying to get file URL");
}
// Always try to use token for protected files
if (useToken) {
try {
console.log(
`Getting file token for ${collectionName}/${recordId}/${filename}`,
);
const token = await this.getFileToken();
console.log(`Got token: ${token.substring(0, 10)}...`);
// Make sure to pass the token as a query parameter
const url = pb.files.getURL(
{ id: recordId, collectionId: collectionName },
filename,
{ token },
);
console.log(`Generated URL with token: ${url.substring(0, 50)}...`);
return url;
} catch (error) {
console.error("Error getting file token:", error);
// Fall back to URL without token
const url = pb.files.getURL(
{ id: recordId, collectionId: collectionName },
filename,
);
console.log(`Fallback URL without token: ${url.substring(0, 50)}...`);
return url;
}
}
// If not using token
const url = pb.files.getURL(
{ id: recordId, collectionId: collectionName },
filename,
);
console.log(`Generated URL without token: ${url.substring(0, 50)}...`);
return url;
}
}

View file

@ -0,0 +1,369 @@
import { Authentication } from "./Authentication";
// Base interface for PocketBase records
interface BaseRecord {
id: string;
[key: string]: any;
}
// Interface for request options
interface RequestOptions {
fields?: string[];
disableAutoCancellation?: boolean;
expand?: string[] | string;
}
// Utility function to check if a value is a UTC date string
function isUTCDateString(value: any): boolean {
if (typeof value !== "string") return false;
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
return isoDateRegex.test(value);
}
// Utility function to format a date to local ISO-like string
function formatLocalDate(date: Date, includeSeconds: boolean = true): string {
const pad = (num: number) => num.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
// Format for datetime-local input (YYYY-MM-DDThh:mm)
if (!includeSeconds) {
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
const seconds = pad(date.getSeconds());
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
}
// Utility function to convert UTC date strings to local time
function convertUTCToLocal<T>(data: T): T {
if (!data || typeof data !== "object") return data;
const converted = { ...data };
for (const [key, value] of Object.entries(converted)) {
// Special handling for event date fields
if (
(key === "start_date" ||
key === "end_date" ||
key === "time_checked_in") &&
isUTCDateString(value)
) {
// Convert UTC date string to local date string
const date = new Date(value);
(converted as any)[key] = formatLocalDate(date, false);
} else if (isUTCDateString(value)) {
// Convert UTC date string to local date string
const date = new Date(value);
(converted as any)[key] = formatLocalDate(date);
} else if (Array.isArray(value)) {
(converted as any)[key] = value.map((item) => convertUTCToLocal(item));
} else if (typeof value === "object" && value !== null) {
(converted as any)[key] = convertUTCToLocal(value);
}
}
return converted;
}
export class Get {
private auth: Authentication;
private static instance: Get;
private constructor() {
this.auth = Authentication.getInstance();
}
/**
* Get the singleton instance of Get
*/
public static getInstance(): Get {
if (!Get.instance) {
Get.instance = new Get();
}
return Get.instance;
}
/**
* Convert UTC date strings to local time
* @param data The data to convert
* @returns The converted data
*/
public static convertUTCToLocal<T>(data: T): T {
return convertUTCToLocal(data);
}
/**
* Check if a value is a UTC date string
* @param value The value to check
* @returns True if the value is a UTC date string
*/
public static isUTCDateString(value: any): boolean {
return isUTCDateString(value);
}
/**
* Format a date to local ISO-like string
* @param date The date to format
* @param includeSeconds Whether to include seconds in the formatted string
* @returns The formatted date string
*/
public static formatLocalDate(
date: Date,
includeSeconds: boolean = true,
): string {
return formatLocalDate(date, includeSeconds);
}
/**
* Get a single record by ID
* @param collectionName The name of the collection
* @param recordId The ID of the record to retrieve
* @param options Optional request options including fields to select and auto-cancellation control
* @returns The requested record
*/
public async getOne<T extends BaseRecord>(
collectionName: string,
recordId: string,
options?: RequestOptions,
): Promise<T> {
if (!this.auth.isAuthenticated()) {
console.warn(
`User not authenticated, but attempting to get record from ${collectionName} anyway`,
);
}
try {
const pb = this.auth.getPocketBase();
// Handle expand parameter
let expandString: string | undefined;
if (options?.expand) {
if (Array.isArray(options.expand)) {
expandString = options.expand.join(",");
} else if (typeof options.expand === "string") {
expandString = options.expand;
}
}
const requestOptions = {
...(options?.fields && { fields: options.fields.join(",") }),
...(expandString && { expand: expandString }),
...(options?.disableAutoCancellation && { requestKey: null }),
};
const result = await pb
.collection(collectionName)
.getOne<T>(recordId, requestOptions);
return convertUTCToLocal(result);
} catch (err) {
console.error(
`Failed to get record ${recordId} from ${collectionName}:`,
err,
);
throw err;
}
}
/**
* Get multiple records by their IDs
* @param collectionName The name of the collection
* @param recordIds Array of record IDs to retrieve
* @param options Optional request options including fields to select and auto-cancellation control
* @returns Array of requested records
*/
public async getMany<T extends BaseRecord>(
collectionName: string,
recordIds: string[],
options?: RequestOptions,
): Promise<T[]> {
if (!this.auth.isAuthenticated()) {
console.warn(
`User not authenticated, but attempting to get records from ${collectionName} anyway`,
);
}
try {
// Build filter for multiple IDs
const filter = recordIds.map((id) => `id="${id}"`).join(" || ");
const pb = this.auth.getPocketBase();
// Handle expand parameter
let expandString: string | undefined;
if (options?.expand) {
if (Array.isArray(options.expand)) {
expandString = options.expand.join(",");
} else if (typeof options.expand === "string") {
expandString = options.expand;
}
}
const requestOptions = {
filter,
...(options?.fields && { fields: options.fields.join(",") }),
...(expandString && { expand: expandString }),
...(options?.disableAutoCancellation && { requestKey: null }),
};
const result = await pb
.collection(collectionName)
.getList<T>(1, recordIds.length, requestOptions);
return result.items.map((item) => convertUTCToLocal(item));
} catch (err) {
console.error(
`Failed to get records ${recordIds.join(", ")} from ${collectionName}:`,
err,
);
throw err;
}
}
/**
* Get records with pagination
* @param collectionName The name of the collection
* @param page Page number (1-based)
* @param perPage Number of items per page
* @param filter Optional filter string
* @param sort Optional sort string
* @param options Optional request options including fields to select and auto-cancellation control
* @returns Paginated list of records
*/
public async getList<T extends BaseRecord>(
collectionName: string,
page: number = 1,
perPage: number = 20,
filter?: string,
sort?: string,
options?: RequestOptions,
): Promise<{
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
}> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to retrieve records");
}
try {
const pb = this.auth.getPocketBase();
const requestOptions = {
...(filter && { filter }),
...(sort && { sort }),
...(options?.fields && { fields: options.fields.join(",") }),
...(options?.disableAutoCancellation && { requestKey: null }),
};
const result = await pb
.collection(collectionName)
.getList<T>(page, perPage, requestOptions);
return {
page: result.page,
perPage: result.perPage,
totalItems: result.totalItems,
totalPages: result.totalPages,
items: result.items.map((item) => convertUTCToLocal(item)),
};
} catch (err) {
console.error(`Failed to get list from ${collectionName}:`, err);
throw err;
}
}
/**
* Get all records from a collection
* @param collectionName The name of the collection
* @param filter Optional filter string
* @param sort Optional sort string
* @param options Optional request options including fields to select and auto-cancellation control
* @returns Array of all matching records
*/
public async getAll<T extends BaseRecord>(
collectionName: string,
filter?: string,
sort?: string,
options?: RequestOptions,
): Promise<T[]> {
if (!this.auth.isAuthenticated()) {
console.warn(
`User not authenticated, but attempting to get records from ${collectionName} anyway`,
);
}
// Try to get records even if authentication check fails
// This is a workaround for cases where isAuthenticated() returns false
// but the token is still valid for API requests
try {
const pb = this.auth.getPocketBase();
// Handle expand parameter
let expandString: string | undefined;
if (options?.expand) {
if (Array.isArray(options.expand)) {
expandString = options.expand.join(",");
} else if (typeof options.expand === "string") {
expandString = options.expand;
}
}
const requestOptions = {
...(filter && { filter }),
...(sort && { sort }),
...(options?.fields && { fields: options.fields.join(",") }),
...(expandString && { expand: expandString }),
...(options?.disableAutoCancellation && { requestKey: null }),
};
const result = await pb
.collection(collectionName)
.getFullList<T>(requestOptions);
return result.map((item) => convertUTCToLocal(item));
} catch (err) {
console.error(`Failed to get all records from ${collectionName}:`, err);
throw err;
}
}
/**
* Get the first record that matches a filter
* @param collectionName The name of the collection
* @param filter Filter string
* @param options Optional request options including fields to select and auto-cancellation control
* @returns The first matching record or null if none found
*/
public async getFirst<T extends BaseRecord>(
collectionName: string,
filter: string,
options?: RequestOptions,
): Promise<T | null> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to retrieve records");
}
try {
const pb = this.auth.getPocketBase();
const requestOptions = {
filter,
...(options?.fields && { fields: options.fields.join(",") }),
...(options?.disableAutoCancellation && { requestKey: null }),
sort: "created",
perPage: 1,
};
const result = await pb
.collection(collectionName)
.getList<T>(1, 1, requestOptions);
return result.items.length > 0
? convertUTCToLocal(result.items[0])
: null;
} catch (err) {
console.error(`Failed to get first record from ${collectionName}:`, err);
throw err;
}
}
}

View file

@ -0,0 +1,191 @@
import { Authentication } from "./Authentication";
import { Collections } from "../../schemas/pocketbase";
import type { Log } from "../../schemas/pocketbase";
// Log data interface for creating new logs
interface LogData {
user: string; // Relation to User
type: string; // Standard types: "error", "update", "delete", "create", "login", "logout"
part: string; // The specific part/section being logged
message: string;
}
export class SendLog {
private auth: Authentication;
private static instance: SendLog;
private readonly COLLECTION_NAME = Collections.LOGS;
private constructor() {
this.auth = Authentication.getInstance();
}
/**
* Get the singleton instance of SendLog
*/
public static getInstance(): SendLog {
if (!SendLog.instance) {
SendLog.instance = new SendLog();
}
return SendLog.instance;
}
/**
* Gets the current authenticated user's ID
* @returns The user ID or null if not authenticated
*/
private getCurrentUserId(): string | null {
const user = this.auth.getCurrentUser();
if (!user) {
console.debug("SendLog: No current user found");
return null;
}
console.debug("SendLog: Current user ID:", user.id);
return user.id;
}
/**
* Sends a log entry to PocketBase
* @param type The type of log entry
* @param part The specific part/section being logged
* @param message The log message
* @param overrideUserId Optional user ID to override the current user
* @returns Promise that resolves when the log is created
*/
public async send(
type: string,
part: string,
message: string,
overrideUserId?: string,
): Promise<void> {
try {
// Check authentication first
if (!this.auth.isAuthenticated()) {
console.error("SendLog: User not authenticated");
throw new Error("User must be authenticated to create logs");
}
// Get user ID
const userId = overrideUserId || this.getCurrentUserId();
if (!userId) {
console.error("SendLog: No user ID available");
throw new Error(
"No user ID available. User must be authenticated to create logs.",
);
}
// Prepare log data
const logData: LogData = {
user: userId,
type,
part,
message,
};
console.debug("SendLog: Preparing to send log:", {
collection: this.COLLECTION_NAME,
data: logData,
authValid: this.auth.isAuthenticated(),
userId,
});
// Get PocketBase instance
const pb = this.auth.getPocketBase();
// Create the log entry
await pb.collection(this.COLLECTION_NAME).create(logData);
console.debug("SendLog: Log created successfully");
} catch (error) {
// Enhanced error logging
if (error instanceof Error) {
console.error("SendLog: Failed to send log:", {
error: error.message,
stack: error.stack,
type,
part,
message,
});
} else {
console.error("SendLog: Unknown error:", error);
}
throw error;
}
}
/**
* Get logs for a specific user
* @param userId The ID of the user to get logs for
* @param type Optional log type to filter by
* @param part Optional part/section to filter by
* @returns Array of log entries
*/
public async getUserLogs(
userId: string,
type?: string,
part?: string,
): Promise<Log[]> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to retrieve logs");
}
try {
let filter = `user = "${userId}"`;
if (type) filter += ` && type = "${type}"`;
if (part) filter += ` && part = "${part}"`;
const result = await this.auth
.getPocketBase()
.collection(this.COLLECTION_NAME)
.getFullList<Log>({
filter,
sort: "-created",
});
return result;
} catch (error) {
console.error("SendLog: Failed to get user logs:", error);
throw error;
}
}
/**
* Get recent logs for the current user
* @param limit Maximum number of logs to retrieve
* @param type Optional log type to filter by
* @param part Optional part/section to filter by
* @returns Array of recent log entries
*/
public async getRecentLogs(
limit: number = 10,
type?: string,
part?: string,
): Promise<Log[]> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to retrieve logs");
}
try {
const userId = this.getCurrentUserId();
if (!userId) {
throw new Error("No user ID available");
}
let filter = `user = "${userId}"`;
if (type) filter += ` && type = "${type}"`;
if (part) filter += ` && part = "${part}"`;
const result = await this.auth
.getPocketBase()
.collection(this.COLLECTION_NAME)
.getList<Log>(1, limit, {
filter,
sort: "-created",
});
return result.items;
} catch (error) {
console.error("SendLog: Failed to get recent logs:", error);
throw error;
}
}
}

View file

@ -0,0 +1,316 @@
import { Authentication } from "./Authentication";
// Utility function to check if a value is a date string
function isLocalDateString(value: any): boolean {
if (typeof value !== "string") return false;
// Match ISO format without the Z suffix (local time)
const isoDateRegex =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:(?:-|\+)\d{2}:\d{2})?$/;
return isoDateRegex.test(value);
}
// Utility function to convert local time to UTC
function convertLocalToUTC<T>(data: T): T {
if (!data || typeof data !== "object") return data;
const converted = { ...data };
for (const [key, value] of Object.entries(converted)) {
// Special handling for event date fields to ensure proper UTC conversion
if (
(key === "start_date" ||
key === "end_date" ||
key === "time_checked_in") &&
typeof value === "string"
) {
// Ensure we're converting to UTC
const date = new Date(value);
(converted as any)[key] = date.toISOString();
}
// Special handling for invoice_data to ensure it's a proper JSON object
else if (key === "invoice_data") {
if (typeof value === "string") {
try {
// If it's a string representation of JSON, parse it
const parsedValue = JSON.parse(value);
(converted as any)[key] = parsedValue;
} catch (e) {
// If it's not valid JSON, keep it as is
console.warn("Failed to parse invoice_data as JSON:", e);
}
} else if (typeof value === "object" && value !== null) {
// If it's already an object, keep it as is
(converted as any)[key] = value;
}
} else if (isLocalDateString(value)) {
// Convert local date string to UTC
const date = new Date(value);
(converted as any)[key] = date.toISOString();
} else if (Array.isArray(value)) {
(converted as any)[key] = value.map((item) => convertLocalToUTC(item));
} else if (typeof value === "object" && value !== null) {
(converted as any)[key] = convertLocalToUTC(value);
}
}
return converted;
}
export class Update {
private auth: Authentication;
private static instance: Update;
private constructor() {
this.auth = Authentication.getInstance();
}
/**
* Get the singleton instance of Update
*/
public static getInstance(): Update {
if (!Update.instance) {
Update.instance = new Update();
}
return Update.instance;
}
/**
* Convert local time to UTC
* @param data The data to convert
* @returns The converted data
*/
public static convertLocalToUTC<T>(data: T): T {
return convertLocalToUTC(data);
}
/**
* Create a new record
* @param collectionName The name of the collection
* @param data The data for the new record
* @returns The created record
*/
public async create<T = any>(
collectionName: string,
data: Record<string, any>,
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to create records");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const convertedData = convertLocalToUTC(data);
const result = await pb
.collection(collectionName)
.create<T>(convertedData);
return result;
} catch (err) {
console.error(`Failed to create record in ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Update a single field in a record
* @param collectionName The name of the collection
* @param recordId The ID of the record to update
* @param field The field to update
* @param value The new value for the field
* @returns The updated record
*/
public async updateField<T = any>(
collectionName: string,
recordId: string,
field: string,
value: any,
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to update records");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const data = { [field]: value };
const convertedData = convertLocalToUTC(data);
const result = await pb
.collection(collectionName)
.update<T>(recordId, convertedData);
return result;
} catch (err) {
console.error(`Failed to update ${field} in ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Update multiple fields in a record
* @param collectionName The name of the collection
* @param recordId The ID of the record to update
* @param updates Object containing field-value pairs to update
* @returns The updated record
*/
public async updateFields<T = any>(
collectionName: string,
recordId: string,
updates: Record<string, any>,
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to update records");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const convertedUpdates = convertLocalToUTC(updates);
// If recordId is empty, create a new record instead of updating
if (!recordId) {
return this.create<T>(collectionName, convertedUpdates);
}
const result = await pb
.collection(collectionName)
.update<T>(recordId, convertedUpdates);
return result;
} catch (err) {
console.error(`Failed to update fields in ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Update a field for multiple records
* @param collectionName The name of the collection
* @param recordIds Array of record IDs to update
* @param field The field to update
* @param value The new value for the field
* @returns Array of updated records
*/
public async batchUpdateField<T = any>(
collectionName: string,
recordIds: string[],
field: string,
value: any,
): Promise<T[]> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to update records");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const data = { [field]: value };
const convertedData = convertLocalToUTC(data);
const updates = recordIds.map((id) =>
pb.collection(collectionName).update<T>(id, convertedData),
);
const results = await Promise.all(updates);
return results;
} catch (err) {
console.error(
`Failed to batch update ${field} in ${collectionName}:`,
err,
);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Update multiple fields for multiple records
* @param collectionName The name of the collection
* @param updates Array of objects containing record ID and updates
* @returns Array of updated records
*/
public async batchUpdateFields<T = any>(
collectionName: string,
updates: Array<{ id: string; data: Record<string, any> }>,
): Promise<T[]> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to update records");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
const updatePromises = updates.map(({ id, data }) =>
pb.collection(collectionName).update<T>(id, convertLocalToUTC(data)),
);
const results = await Promise.all(updatePromises);
return results;
} catch (err) {
console.error(`Failed to batch update fields in ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
/**
* Update a record with file appends
* This method properly handles appending files to existing records
* @param collectionName The name of the collection
* @param recordId The ID of the record to update
* @param data Regular fields to update
* @param files Object mapping field names to arrays of files to append
* @returns The updated record
*/
public async updateWithFileAppends<T = any>(
collectionName: string,
recordId: string,
data: Record<string, any> = {},
files: Record<string, File[]> = {}
): Promise<T> {
if (!this.auth.isAuthenticated()) {
throw new Error("User must be authenticated to update records");
}
try {
this.auth.setUpdating(true);
const pb = this.auth.getPocketBase();
// Convert regular data fields
const convertedData = convertLocalToUTC(data);
// Create FormData for the update
const formData = new FormData();
// Add regular fields
Object.entries(convertedData).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value.toString());
}
});
// Add files with the + prefix to append them
Object.entries(files).forEach(([fieldName, fieldFiles]) => {
fieldFiles.forEach(file => {
formData.append(`${fieldName}+`, file);
});
});
// Perform the update
const result = await pb
.collection(collectionName)
.update<T>(recordId, formData);
return result;
} catch (err) {
console.error(`Failed to update with file appends in ${collectionName}:`, err);
throw err;
} finally {
this.auth.setUpdating(false);
}
}
}

37
src/utils/roleAccess.ts Normal file
View file

@ -0,0 +1,37 @@
export type OfficerStatus =
| "administrator"
| "executive"
| "general"
| "honorary"
| "past"
| "sponsor"
| "none"
| "";
type RoleHierarchy = Record<OfficerStatus, OfficerStatus[]>;
export function hasAccess(
userRole: OfficerStatus,
requiredRole: OfficerStatus,
): boolean {
const roleHierarchy: RoleHierarchy = {
administrator: [
"administrator",
"executive",
"general",
"honorary",
"past",
"sponsor",
"none",
"",
],
executive: ["executive", "general", "honorary", "past", "none", ""],
general: ["general", "honorary", "past", "none", ""],
honorary: ["honorary", "none", ""],
past: ["past", "none", ""],
sponsor: ["sponsor"], // Sponsor can only access sponsor-specific content
none: ["none", ""],
"": [""],
};
return roleHierarchy[userRole]?.includes(requiredRole) || false;
}

View file

@ -9,7 +9,8 @@ export default {
"animate-delay-100", "animate-delay-100",
"animate-delay-300", "animate-delay-300",
"animate-delay-500", "animate-delay-500",
"animate-delay-700"], "animate-delay-700",
],
theme: { theme: {
extend: { extend: {
boxShadow: { boxShadow: {
@ -35,7 +36,9 @@ export default {
plugins: [ plugins: [
require("tailwindcss-motion"), require("tailwindcss-motion"),
require("tailwindcss-animated"), require("tailwindcss-animated"),
require("daisyui"),
function ({ addVariant }) { function ({ addVariant }) {
addVariant("in-view", "&.in-view"); addVariant("in-view", "&.in-view");
},], },
],
}; };

View file

@ -1,14 +1,10 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [ "include": [".astro/types.d.ts", "**/*"],
".astro/types.d.ts", "exclude": ["dist"],
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react",
"esModuleInterop": true
} }
} }