From 9b49a8f0886a6606904b93b00d9e89cf52d77f58 Mon Sep 17 00:00:00 2001 From: chark1es Date: Fri, 31 Jan 2025 06:36:46 -0800 Subject: [PATCH] added profile separated profile from online-store (kind of) added event management, editor, and viewer --- bun.lock | 23 + package.json | 1 + src/components/auth/EventAuth.ts | 1084 ++++++++++++++++- src/components/auth/EventCheckIn.ts | 244 ++++ src/components/auth/StoreAuth.ts | 34 +- src/components/auth/UserProfile.astro | 43 +- .../profile/DefaultProfileView.astro | 439 +++++++ src/components/profile/EventEditor.astro | 270 ++++ src/components/profile/EventManagement.astro | 260 ++++ .../{store => profile}/MemberManagement.astro | 6 +- .../OfficerView.astro} | 0 src/components/store/EventEditor.astro | 96 -- src/components/store/EventManagement.astro | 82 -- src/data/storeConfig.yaml | 57 + src/pages/online-store.astro | 4 +- src/pages/profile.astro | 50 + 16 files changed, 2444 insertions(+), 249 deletions(-) create mode 100644 src/components/auth/EventCheckIn.ts create mode 100644 src/components/profile/DefaultProfileView.astro create mode 100644 src/components/profile/EventEditor.astro create mode 100644 src/components/profile/EventManagement.astro rename src/components/{store => profile}/MemberManagement.astro (93%) rename src/components/{store/OfficerStoreView.astro => profile/OfficerView.astro} (100%) delete mode 100644 src/components/store/EventEditor.astro delete mode 100644 src/components/store/EventManagement.astro create mode 100644 src/pages/profile.astro diff --git a/bun.lock b/bun.lock index 4ee959a..8bdbc34 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "astro-expressive-code": "^0.38.3", "astro-icon": "^1.1.4", "js-yaml": "^4.1.0", + "jszip": "^3.10.1", "motion": "^11.15.0", "next": "^15.1.2", "pocketbase": "^0.25.1", @@ -500,6 +501,8 @@ "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=="], "crossws": ["crossws@0.3.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw=="], @@ -730,6 +733,8 @@ "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=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -772,6 +777,8 @@ "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=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -786,10 +793,14 @@ "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=="], "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=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1014,6 +1025,8 @@ "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-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=="], @@ -1072,6 +1085,8 @@ "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=="], "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1096,6 +1111,8 @@ "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=="], "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=="], @@ -1154,6 +1171,8 @@ "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=="], "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], @@ -1166,6 +1185,8 @@ "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=="], "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=="], @@ -1200,6 +1221,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_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=="], "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], diff --git a/package.json b/package.json index 56fcc98..3fc19bd 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "astro-expressive-code": "^0.38.3", "astro-icon": "^1.1.4", "js-yaml": "^4.1.0", + "jszip": "^3.10.1", "motion": "^11.15.0", "next": "^15.1.2", "pocketbase": "^0.25.1", diff --git a/src/components/auth/EventAuth.ts b/src/components/auth/EventAuth.ts index a86d70f..6fa72cc 100644 --- a/src/components/auth/EventAuth.ts +++ b/src/components/auth/EventAuth.ts @@ -1,6 +1,8 @@ import PocketBase from "pocketbase"; +import type { RecordModel } from "pocketbase"; import yaml from "js-yaml"; import configYaml from "../../data/storeConfig.yaml?raw"; +import JSZip from 'jszip'; // Configuration type definitions interface Config { @@ -19,6 +21,28 @@ interface Config { messageTimeout: number; }; }; + tables: { + events: { + title: string; + columns: { + event_name: string; + event_id: string; + event_code: string; + start_date: string; + end_date: string; + points_to_reward: string; + location: string; + registered_users: string; + actions: string; + }; + form: { + buttons: { + edit: string; + delete: string; + }; + }; + }; + }; defaults: { pageSize: number; sortField: string; @@ -28,16 +52,19 @@ interface Config { // Parse YAML configuration with type const config = yaml.load(configYaml) as Config; +const { columns, form } = config.ui.tables.events; interface Event { id: string; event_id: string; event_name: string; event_code: string; - registered_users: string; // JSON string + attendees: string; // JSON string of attendee IDs points_to_reward: number; start_date: string; end_date: string; + location: string; + files: string[]; // Array of file URLs collectionId: string; collectionName: string; } @@ -52,15 +79,32 @@ interface AuthElements { editorEventName: HTMLInputElement; editorEventCode: HTMLInputElement; editorStartDate: HTMLInputElement; + editorStartTime: HTMLInputElement; editorEndDate: HTMLInputElement; + editorEndTime: HTMLInputElement; editorPointsToReward: HTMLInputElement; + editorLocation: HTMLInputElement; + editorFiles: HTMLInputElement; + currentFiles: HTMLDivElement; saveEventButton: HTMLButtonElement; } +interface ValidationErrors { + eventId?: string; + eventName?: string; + eventCode?: string; + startDate?: string; + startTime?: string; + endDate?: string; + endTime?: string; + points?: string; +} + export class EventAuth { private pb: PocketBase; private elements: AuthElements; private cachedEvents: Event[] = []; + private abortController: AbortController | null = null; constructor() { this.pb = new PocketBase(config.api.baseUrl); @@ -78,8 +122,13 @@ export class EventAuth { const editorEventName = document.getElementById("editorEventName") as HTMLInputElement; const editorEventCode = document.getElementById("editorEventCode") as HTMLInputElement; const editorStartDate = document.getElementById("editorStartDate") as HTMLInputElement; + const editorStartTime = document.getElementById("editorStartTime") as HTMLInputElement; const editorEndDate = document.getElementById("editorEndDate") as HTMLInputElement; + const editorEndTime = document.getElementById("editorEndTime") as HTMLInputElement; const editorPointsToReward = document.getElementById("editorPointsToReward") as HTMLInputElement; + const editorLocation = document.getElementById("editorLocation") as HTMLInputElement; + const editorFiles = document.getElementById("editorFiles") as HTMLInputElement; + const currentFiles = document.getElementById("currentFiles") as HTMLDivElement; const saveEventButton = document.getElementById("saveEventButton") as HTMLButtonElement; if ( @@ -92,8 +141,13 @@ export class EventAuth { !editorEventName || !editorEventCode || !editorStartDate || + !editorStartTime || !editorEndDate || + !editorEndTime || !editorPointsToReward || + !editorLocation || + !editorFiles || + !currentFiles || !saveEventButton ) { throw new Error("Required DOM elements not found"); @@ -109,23 +163,47 @@ export class EventAuth { editorEventName, editorEventCode, editorStartDate, + editorStartTime, editorEndDate, + editorEndTime, editorPointsToReward, + editorLocation, + editorFiles, + currentFiles, saveEventButton, }; } private getRegisteredUsersCount(registeredUsers: string): number { + // Handle different cases for registered_users field + if (!registeredUsers) return 0; + try { - if (!registeredUsers) return 0; + // Try to parse if it's a JSON string const users = JSON.parse(registeredUsers); - return Array.isArray(users) ? users.length : 0; + // Ensure users is an array + if (!Array.isArray(users)) { + return 0; + } + return users.length; } catch (err) { - console.warn("Failed to parse registered_users:", err); + console.warn("Failed to parse registered_users, using 0"); return 0; } } + private parseArrayField(field: any, defaultValue: any[] = []): any[] { + if (!field) return defaultValue; + if (Array.isArray(field)) return field; + try { + const parsed = JSON.parse(field); + return Array.isArray(parsed) ? parsed : defaultValue; + } catch (err) { + console.warn("Failed to parse array field:", err); + return defaultValue; + } + } + private async fetchEvents(searchQuery: string = "") { try { // Only fetch from API if we don't have cached data @@ -145,7 +223,8 @@ export class EventAuth { return terms.every(term => (event.event_name?.toLowerCase().includes(term) || event.event_id?.toLowerCase().includes(term) || - event.event_code?.toLowerCase().includes(term)) + event.event_code?.toLowerCase().includes(term) || + event.location?.toLowerCase().includes(term)) ); }); } @@ -157,7 +236,7 @@ export class EventAuth { if (filteredEvents.length === 0) { const row = document.createElement("tr"); row.innerHTML = ` - + ${searchQuery ? "No events found matching your search." : "No events found."} `; @@ -167,57 +246,105 @@ export class EventAuth { const row = document.createElement("tr"); const startDate = event.start_date ? new Date(event.start_date).toLocaleString() : "N/A"; const endDate = event.end_date ? new Date(event.end_date).toLocaleString() : "N/A"; - const registeredCount = this.getRegisteredUsersCount(event.registered_users); + + // Parse attendees using the new helper method + const attendees = this.parseArrayField(event.attendees); + const attendeeCount = attendees.length; + + // Handle files display + const filesHtml = event.files && Array.isArray(event.files) && event.files.length > 0 + ? `` + : 'No files'; + + // Format dates for display + const formatDateTime = (dateStr: string) => { + if (!dateStr) return { dateDisplay: 'N/A', timeDisplay: 'N/A' }; + const date = new Date(dateStr); + return { + dateDisplay: date.toLocaleDateString(), + timeDisplay: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + }; + }; + + const startDateTime = formatDateTime(event.start_date); + const endDateTime = formatDateTime(event.end_date); row.innerHTML = `
-
${event.event_name || "N/A"}
-
Event ID: ${event.event_id || "N/A"}
-
Code: ${event.event_code || "N/A"}
-
Start: ${startDate}
-
End: ${endDate}
-
Points: ${event.points_to_reward || 0}
-
Registered: ${registeredCount}
-
+
${event.event_name || "N/A"}
+
${columns.event_id}: ${event.event_id || "N/A"}
+
${columns.event_code}: ${event.event_code || "N/A"}
+
${columns.start_date}: ${startDateTime.dateDisplay}
${startDateTime.timeDisplay}
+
${columns.end_date}: ${endDateTime.dateDisplay}
${endDateTime.timeDisplay}
+
${columns.points_to_reward}: ${event.points_to_reward || 0}
+
${columns.location}: ${event.location || "N/A"}
+
Files: ${filesHtml}
+
Attendees: ${attendeeCount}
+
+
- + - ${event.event_id || "N/A"} - ${event.event_code || "N/A"} - ${startDate} - ${endDate} - ${event.points_to_reward || 0} - ${registeredCount} - -
+ ${event.event_id || "N/A"} + ${event.event_code || "N/A"} + +
+ ${startDateTime.dateDisplay} + ${startDateTime.timeDisplay} +
+ + +
+ ${endDateTime.dateDisplay} + ${endDateTime.timeDisplay} +
+ + ${event.points_to_reward || 0} + ${event.location || "N/A"} + ${filesHtml} + + + + +
@@ -230,7 +357,7 @@ export class EventAuth { eventList.innerHTML = ""; eventList.appendChild(fragment); - // Setup event listeners for edit and delete buttons + // Setup event listeners for buttons const editButtons = eventList.querySelectorAll(".edit-event"); editButtons.forEach((button) => { button.addEventListener("click", () => { @@ -250,12 +377,32 @@ export class EventAuth { } }); }); + + const viewAttendeesButtons = eventList.querySelectorAll(".view-attendees"); + viewAttendeesButtons.forEach((button) => { + button.addEventListener("click", () => { + const eventId = (button as HTMLButtonElement).dataset.eventId; + if (eventId) { + this.handleViewAttendees(eventId); + } + }); + }); + + const viewFilesButtons = eventList.querySelectorAll(".view-files"); + viewFilesButtons.forEach((button) => { + button.addEventListener("click", async () => { + const eventId = (button as HTMLButtonElement).dataset.eventId; + if (eventId) { + await this.handleViewFiles(eventId); + } + }); + }); } catch (err) { console.error("Failed to fetch events:", err); const { eventList } = this.elements; eventList.innerHTML = ` - + Failed to fetch events. Please try again. @@ -263,6 +410,30 @@ export class EventAuth { } } + private splitDateTime(dateTimeStr: string): { date: string; time: string } { + if (!dateTimeStr) return { date: "", time: "" }; + const dateTime = new Date(dateTimeStr); + const date = dateTime.toISOString().split('T')[0]; + const time = dateTime.toTimeString().split(' ')[0].slice(0, 5); + return { date, time }; + } + + private combineDateTime(date: string, time: string): string { + if (!date || !time) return ""; + return `${date}T${time}:00`; + } + + private getFileNameFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split("/"); + return decodeURIComponent(pathParts[pathParts.length - 1]); + } catch (e) { + // If URL parsing fails, try to get the filename from the path directly + return url.split("/").pop() || "Unknown File"; + } + } + private async handleEventEdit(eventId: string) { try { const event = await this.pb.collection("events").getOne(eventId); @@ -272,18 +443,77 @@ export class EventAuth { editorEventName, editorEventCode, editorStartDate, + editorStartTime, editorEndDate, + editorEndTime, editorPointsToReward, + editorLocation, + currentFiles, saveEventButton, } = this.elements; + // Split start and end dates into separate date and time + const startDateTime = this.splitDateTime(event.start_date); + const endDateTime = this.splitDateTime(event.end_date); + // Populate the form editorEventId.value = event.event_id || ""; editorEventName.value = event.event_name || ""; editorEventCode.value = event.event_code || ""; - editorStartDate.value = event.start_date ? new Date(event.start_date).toISOString().slice(0, 16) : ""; - editorEndDate.value = event.end_date ? new Date(event.end_date).toISOString().slice(0, 16) : ""; + editorStartDate.value = startDateTime.date; + editorStartTime.value = startDateTime.time; + editorEndDate.value = endDateTime.date; + editorEndTime.value = endDateTime.time; editorPointsToReward.value = event.points_to_reward?.toString() || "0"; + editorLocation.value = event.location || ""; + + // Display current files + this.updateFilesDisplay(event); + + // Update file input to support multiple files + const fileInput = this.elements.editorFiles; + fileInput.setAttribute('multiple', 'true'); + fileInput.setAttribute('accept', '*/*'); + + // Add file input change handler for automatic upload + fileInput.addEventListener('change', async () => { + const selectedFiles = fileInput.files; + if (selectedFiles && selectedFiles.length > 0) { + try { + // Create FormData for file upload + const formData = new FormData(); + + // Add existing files to the formData + if (event.files && Array.isArray(event.files)) { + formData.append("files", JSON.stringify(event.files)); + } + + // Add new files to the formData + Array.from(selectedFiles).forEach(file => { + formData.append("files", file); + }); + + // Update the event with new files + const updatedEvent = await this.pb.collection('events').update(event.id, formData); + + // Update the files display without refreshing the entire editor + event.files = updatedEvent.files; + this.updateFilesDisplay(event); + + // Clear the file input + fileInput.value = ''; + + // Clear the file name display + const fileNameDisplay = fileInput.parentElement?.querySelector('.file-name'); + if (fileNameDisplay) { + fileNameDisplay.setAttribute('data-content', ''); + } + } catch (err) { + console.error('Failed to upload files:', err); + alert('Failed to upload files. Please try again.'); + } + } + }); // Store the event ID for saving saveEventButton.dataset.eventId = eventId; @@ -298,6 +528,152 @@ export class EventAuth { } } + private validateForm(): ValidationErrors | null { + const { + editorEventId, + editorEventName, + editorEventCode, + editorStartDate, + editorStartTime, + editorEndDate, + editorEndTime, + editorPointsToReward, + } = this.elements; + + const errors: ValidationErrors = {}; + + // Reset all error messages + const errorElements = document.querySelectorAll('.label-text-alt.text-error'); + errorElements.forEach(el => el.classList.add('hidden')); + + // Event ID validation + if (!editorEventId.disabled) { // Only validate if it's a new event + if (!editorEventId.value) { + errors.eventId = "Event ID is required"; + } else if (!editorEventId.value.match(/^[A-Za-z0-9_-]+$/)) { + errors.eventId = "Event ID can only contain letters, numbers, underscores, and hyphens"; + } else if (editorEventId.value.length < 3) { + errors.eventId = "Event ID must be at least 3 characters"; + } else if (editorEventId.value.length > 50) { + errors.eventId = "Event ID must be less than 50 characters"; + } + } + + // Event Name validation + if (!editorEventName.value) { + errors.eventName = "Event Name is required"; + } else if (editorEventName.value.length < 3) { + errors.eventName = "Event Name must be at least 3 characters"; + } else if (editorEventName.value.length > 100) { + errors.eventName = "Event Name must be less than 100 characters"; + } + + // Event Code validation + if (!editorEventCode.value) { + errors.eventCode = "Event Code is required"; + } else if (!editorEventCode.value.match(/^[A-Za-z0-9_-]+$/)) { + errors.eventCode = "Event Code can only contain letters, numbers, underscores, and hyphens"; + } else if (editorEventCode.value.length < 3) { + errors.eventCode = "Event Code must be at least 3 characters"; + } else if (editorEventCode.value.length > 20) { + errors.eventCode = "Event Code must be less than 20 characters"; + } + + // Date and Time validation + if (!editorStartDate.value) { + errors.startDate = "Start Date is required"; + } + if (!editorStartTime.value) { + errors.startTime = "Start Time is required"; + } + if (!editorEndDate.value) { + errors.endDate = "End Date is required"; + } + if (!editorEndTime.value) { + errors.endTime = "End Time is required"; + } + + // Validate that end date/time is after start date/time + if (editorStartDate.value && editorStartTime.value && editorEndDate.value && editorEndTime.value) { + const startDateTime = new Date(`${editorStartDate.value}T${editorStartTime.value}`); + const endDateTime = new Date(`${editorEndDate.value}T${editorEndTime.value}`); + + if (endDateTime <= startDateTime) { + errors.endDate = "End date/time must be after start date/time"; + } + } + + // Points validation + const points = parseInt(editorPointsToReward.value); + if (!editorPointsToReward.value) { + errors.points = "Points are required"; + } else if (isNaN(points) || points < 0) { + errors.points = "Points must be a positive number"; + } else if (points > 1000) { + errors.points = "Points must be less than 1000"; + } + + // Show error messages + if (errors.eventId) { + const errorEl = document.getElementById('eventIdError'); + if (errorEl) { + errorEl.textContent = errors.eventId; + errorEl.classList.remove('hidden'); + } + } + if (errors.eventName) { + const errorEl = document.getElementById('eventNameError'); + if (errorEl) { + errorEl.textContent = errors.eventName; + errorEl.classList.remove('hidden'); + } + } + if (errors.eventCode) { + const errorEl = document.getElementById('eventCodeError'); + if (errorEl) { + errorEl.textContent = errors.eventCode; + errorEl.classList.remove('hidden'); + } + } + if (errors.startDate) { + const errorEl = document.getElementById('startDateError'); + if (errorEl) { + errorEl.textContent = errors.startDate; + errorEl.classList.remove('hidden'); + } + } + if (errors.startTime) { + const errorEl = document.getElementById('startTimeError'); + if (errorEl) { + errorEl.textContent = errors.startTime; + errorEl.classList.remove('hidden'); + } + } + if (errors.endDate) { + const errorEl = document.getElementById('endDateError'); + if (errorEl) { + errorEl.textContent = errors.endDate; + errorEl.classList.remove('hidden'); + } + } + if (errors.endTime) { + const errorEl = document.getElementById('endTimeError'); + if (errorEl) { + errorEl.textContent = errors.endTime; + errorEl.classList.remove('hidden'); + } + } + if (errors.points) { + const errorEl = document.getElementById('pointsError'); + if (errorEl) { + errorEl.textContent = errors.points; + errorEl.classList.remove('hidden'); + } + } + + return Object.keys(errors).length > 0 ? errors : null; + } + private async handleEventSave() { const { eventEditor, @@ -305,33 +681,65 @@ export class EventAuth { editorEventName, editorEventCode, editorStartDate, + editorStartTime, editorEndDate, + editorEndTime, editorPointsToReward, + editorLocation, + editorFiles, saveEventButton, } = this.elements; const eventId = saveEventButton.dataset.eventId; const isNewEvent = !eventId; - try { - const eventData: Record = { - event_name: editorEventName.value, - event_code: editorEventCode.value, - start_date: editorStartDate.value, - end_date: editorEndDate.value, - points_to_reward: parseInt(editorPointsToReward.value) || 0, - }; + // Validate form before proceeding + const errors = this.validateForm(); + if (errors) { + return; // Stop if there are validation errors + } - // Only set registered_users for new events + try { + // Combine date and time inputs + const startDateTime = this.combineDateTime(editorStartDate.value, editorStartTime.value); + const endDateTime = this.combineDateTime(editorEndDate.value, editorEndTime.value); + + // Create FormData for file upload + const formData = new FormData(); + formData.append("event_name", editorEventName.value); + formData.append("event_code", editorEventCode.value); + formData.append("start_date", startDateTime); + formData.append("end_date", endDateTime); + formData.append("points_to_reward", editorPointsToReward.value); + formData.append("location", editorLocation.value); + + // For new events, set event_id and initialize attendees as empty array if (isNewEvent) { - eventData.event_id = editorEventId.value; - eventData.registered_users = "[]"; + formData.append("event_id", editorEventId.value); + formData.append("attendees", "[]"); // Initialize with empty array + } else { + // For existing events, preserve current attendees and files + const currentEvent = await this.pb.collection("events").getOne(eventId); + const currentAttendees = this.parseArrayField(currentEvent.attendees, []); + formData.append("attendees", JSON.stringify(currentAttendees)); + + // Preserve existing files if no new files are being uploaded + if (currentEvent.files && (!editorFiles.files || editorFiles.files.length === 0)) { + formData.append("files", JSON.stringify(currentEvent.files)); + } + } + + // Handle file uploads + if (editorFiles.files && editorFiles.files.length > 0) { + Array.from(editorFiles.files).forEach(file => { + formData.append("files", file); + }); } if (isNewEvent) { - await this.pb.collection("events").create(eventData); + await this.pb.collection("events").create(formData); } else { - await this.pb.collection("events").update(eventId, eventData); + await this.pb.collection("events").update(eventId, formData); } // Close the dialog and refresh the table @@ -355,7 +763,289 @@ export class EventAuth { } } + private async handleViewAttendees(eventId: string) { + try { + const event = await this.pb.collection("events").getOne(eventId); + const attendees = this.parseArrayField(event.attendees); + + // Fetch user details for each attendee + const userDetails = await Promise.all( + attendees.map(async (userId: string) => { + try { + const user = await this.pb.collection("users").getOne(userId); + return { + name: user.name || "N/A", + email: user.email || "N/A", + member_id: user.member_id || "N/A" + }; + } catch (err) { + console.warn(`Failed to fetch user ${userId}:`, err); + return { + name: "Unknown User", + email: "N/A", + member_id: "N/A" + }; + } + }) + ); + + // Create and show modal + const modal = document.createElement("dialog"); + modal.className = "modal"; + modal.innerHTML = ` + + + `; + + document.body.appendChild(modal); + modal.showModal(); + + // Add search functionality + const searchInput = modal.querySelector("#attendeeSearch") as HTMLInputElement; + const attendeeRows = modal.querySelectorAll(".attendee-row"); + + if (searchInput) { + searchInput.addEventListener("input", () => { + const searchTerm = searchInput.value.toLowerCase(); + attendeeRows.forEach((row) => { + const text = row.textContent?.toLowerCase() || ""; + if (text.includes(searchTerm)) { + (row as HTMLElement).style.display = ""; + } else { + (row as HTMLElement).style.display = "none"; + } + }); + }); + } + + // Remove modal when closed + modal.addEventListener("close", () => { + document.body.removeChild(modal); + }); + } catch (err) { + console.error("Failed to view attendees:", err); + } + } + + private async syncAttendees() { + try { + // Cancel any existing sync operation + if (this.abortController) { + this.abortController.abort(); + } + + // Create new abort controller for this sync operation + this.abortController = new AbortController(); + + console.log("=== STARTING ATTENDEE SYNC ==="); + + // Fetch all events first with abort signal + const events = await this.pb.collection("events").getFullList({ + sort: config.ui.defaults.sortField, + $cancelKey: "syncAttendees", + }); + + // Early return if aborted + if (this.abortController.signal.aborted) { + console.log("Sync operation was cancelled"); + return; + } + + console.log("=== EVENTS DATA ==="); + events.forEach(event => { + console.log(`Event: ${event.event_name} (ID: ${event.id})`); + console.log("- event_id:", event.event_id); + console.log("- Raw attendees field:", event.attendees); + }); + + // Create a map of event_id to event record for faster lookup + const eventMap = new Map(); + events.forEach(event => { + const currentAttendees = this.parseArrayField(event.attendees); + console.log(`Parsed attendees for event ${event.event_name}:`, currentAttendees); + + eventMap.set(event.event_id, { + id: event.id, + event_id: event.event_id, + event_name: event.event_name, + attendees: new Set(currentAttendees) + }); + console.log(`Mapped event ${event.event_name} with event_id ${event.event_id}`); + }); + + // Check if aborted before fetching users + if (this.abortController.signal.aborted) { + console.log("Sync operation was cancelled"); + return; + } + + // Fetch all users with abort signal + const users = await this.pb.collection("users").getFullList({ + fields: "id,name,email,member_id,events_attended", + $cancelKey: "syncAttendees", + }); + + console.log("=== USERS DATA ==="); + users.forEach(user => { + console.log(`User: ${user.name || 'Unknown'} (ID: ${user.id})`); + console.log("- Raw events_attended:", user.events_attended); + }); + + // Process each user's events_attended + for (const user of users) { + // Check if aborted before processing each user + if (this.abortController.signal.aborted) { + console.log("Sync operation was cancelled"); + return; + } + + console.log(`\nProcessing user: ${user.name || 'Unknown'} (ID: ${user.id})`); + const eventsAttended = this.parseArrayField(user.events_attended); + console.log("Parsed events_attended:", eventsAttended); + + // For each event the user attended + for (const eventId of eventsAttended) { + console.log(`\nChecking event ${eventId} for user ${user.id}`); + + // Find the event by event_id + const eventRecord = eventMap.get(eventId); + if (eventRecord) { + console.log(`Found event record:`, eventRecord); + // If user not already in attendees, add them + if (!eventRecord.attendees.has(user.id)) { + eventRecord.attendees.add(user.id); + console.log(`Added user ${user.id} to event ${eventId}`); + } else { + console.log(`User ${user.id} already in event ${eventId}`); + } + } else { + console.log(`Event ${eventId} not found in event map. Available event_ids:`, + Array.from(eventMap.keys())); + } + } + } + + // Update all events with new attendee lists + console.log("\n=== UPDATING EVENTS ==="); + for (const [eventId, record] of eventMap.entries()) { + // Check if aborted before each update + if (this.abortController.signal.aborted) { + console.log("Sync operation was cancelled"); + return; + } + + try { + const attendeeArray = Array.from(record.attendees); + console.log(`Updating event ${eventId}:`); + console.log("- Current attendees:", attendeeArray); + + await this.pb.collection("events").update(record.id, { + attendees: JSON.stringify(attendeeArray) + }, { + $cancelKey: "syncAttendees", + }); + console.log(`Successfully updated event ${eventId}`); + } catch (err: any) { + if (err?.name === "AbortError") { + console.log("Update was cancelled"); + return; + } + console.error(`Failed to update attendees for event ${eventId}:`, err); + console.error("Failed record:", record); + } + } + + // Clear the cache to force a refresh of the events list + this.cachedEvents = []; + console.log("\n=== SYNC COMPLETE ==="); + await this.fetchEvents(); + } catch (err: any) { + if (err?.name === "AbortError") { + console.log("Sync operation was cancelled"); + return; + } + console.error("Failed to sync attendees:", err); + console.error("Full error:", err); + } finally { + // Clear the abort controller when done + this.abortController = null; + } + } + + private cleanup() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private async refreshEventsAndSync() { + try { + // Clear the cache to force a fresh fetch + this.cachedEvents = []; + + // Check if user is authorized to sync + const user = this.pb.authStore.model; + if (user && (user.member_type === "IEEE Officer" || user.member_type === "IEEE Administrator")) { + await this.syncAttendees().catch(console.error); + } else { + // If not authorized to sync, just refresh the events + await this.fetchEvents(); + } + } catch (err) { + console.error("Failed to refresh events:", err); + } + } + private init() { + // Only sync attendees if user is an officer or administrator + setTimeout(async () => { + const user = this.pb.authStore.model; + if (user && (user.member_type === "IEEE Officer" || user.member_type === "IEEE Administrator")) { + await this.syncAttendees().catch(console.error); + } + }, 100); + // Initial fetch this.fetchEvents(); @@ -371,6 +1061,14 @@ export class EventAuth { // Search button click handler this.elements.searchEvents.addEventListener("click", handleSearch); + // Refresh button click handler + const refreshButton = document.getElementById("refreshEvents"); + if (refreshButton) { + refreshButton.addEventListener("click", () => { + this.refreshEventsAndSync(); + }); + } + // Add event button this.elements.addEvent.addEventListener("click", () => { const { eventEditor, editorEventId, saveEventButton } = this.elements; @@ -380,7 +1078,9 @@ export class EventAuth { this.elements.editorEventName.value = ""; this.elements.editorEventCode.value = ""; this.elements.editorStartDate.value = ""; + this.elements.editorStartTime.value = ""; this.elements.editorEndDate.value = ""; + this.elements.editorEndTime.value = ""; this.elements.editorPointsToReward.value = "0"; // Enable event_id field for new events @@ -409,4 +1109,298 @@ export class EventAuth { this.handleEventSave(); }); } + + // Add this new method to handle files display update + private updateFilesDisplay(event: RecordModel) { + const { currentFiles } = this.elements; + currentFiles.innerHTML = ""; + + if (event.files && Array.isArray(event.files) && event.files.length > 0) { + const filesList = document.createElement("div"); + filesList.className = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"; + + event.files.forEach((file: string) => { + const fileUrl = this.pb.files.getURL(event, file); + const fileName = this.getFileNameFromUrl(file); + const fileExt = fileName.split('.').pop()?.toLowerCase() || ''; + + const fileItem = document.createElement("div"); + fileItem.className = "bg-base-200 rounded-lg overflow-hidden"; + + // Generate preview based on file type + let previewHtml = ''; + if (['jpg', 'jpeg', 'png', 'gif'].includes(fileExt)) { + previewHtml = ` +
+ ${fileName} +
+ `; + } else if (fileExt === 'pdf') { + previewHtml = ` +
+ +
+ `; + } else { + // For other file types, show an icon based on type + const iconHtml = fileExt === 'txt' || fileExt === 'md' + ? ` + + ` + : ` + + `; + + previewHtml = ` +
+ ${iconHtml} +
+ `; + } + + fileItem.innerHTML = ` + ${previewHtml} +
+
+ ${fileName} +
+
+ + + + + + + +
+
+ `; + + // Add delete handler + const deleteButton = fileItem.querySelector('.text-error'); + if (deleteButton) { + deleteButton.addEventListener('click', async () => { + if (confirm('Are you sure you want to remove this file?')) { + try { + const fileToRemove = deleteButton.getAttribute('data-file'); + if (!fileToRemove) throw new Error('File not found'); + + // Get the current event data to ensure we have the latest state + const currentEvent = await this.pb.collection('events').getOne(event.id); + + // Filter out the file to be removed + const updatedFiles = currentEvent.files.filter((f: string) => f !== fileToRemove); + + // Update the event with the new files array + await this.pb.collection('events').update(event.id, { + files: updatedFiles + }); + + // Update the event.files array in memory + event.files = updatedFiles; + + // Remove the file item from the UI + fileItem.remove(); + + // If no files left, show the "No files" message + if (updatedFiles.length === 0) { + currentFiles.innerHTML = 'No files'; + } + } catch (err) { + console.error('Failed to remove file:', err); + alert('Failed to remove file. Please try again.'); + } + } + }); + } + + filesList.appendChild(fileItem); + }); + + currentFiles.appendChild(filesList); + } else { + currentFiles.innerHTML = 'No files'; + } + } + + private async handleViewFiles(eventId: string) { + try { + const event = await this.pb.collection("events").getOne(eventId); + + // Create and show modal + const modal = document.createElement("dialog"); + modal.className = "modal"; + modal.innerHTML = ` + + + `; + + document.body.appendChild(modal); + modal.showModal(); + + // Add preview functionality + const previewButtons = modal.querySelectorAll('.preview-file'); + previewButtons.forEach(button => { + button.addEventListener('click', () => { + const url = (button as HTMLButtonElement).dataset.url; + const fileName = (button as HTMLButtonElement).dataset.filename; + const ext = (button as HTMLButtonElement).dataset.ext; + if (url && fileName) { + if (ext === 'pdf') { + window.open(url, '_blank'); + } else { + document.dispatchEvent(new CustomEvent('showFilePreview', { + detail: { url, fileName } + })); + } + } + }); + }); + + // Add download functionality + const downloadButton = modal.querySelector('.download-all'); + if (downloadButton && event.files && Array.isArray(event.files)) { + downloadButton.addEventListener('click', async () => { + try { + if (event.files.length === 1) { + // For single file, download directly + const fileUrl = this.pb.files.getURL(event, event.files[0]); + const fileName = this.getFileNameFromUrl(event.files[0]); + const response = await fetch(fileUrl); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } else { + // For multiple files, create a zip + const zip = new JSZip(); + + // Show loading state + downloadButton.classList.add('loading'); + downloadButton.setAttribute('disabled', 'true'); + + // Download all files and add to zip + await Promise.all(event.files.map(async (file: string) => { + const fileUrl = this.pb.files.getURL(event, file); + const fileName = this.getFileNameFromUrl(file); + const response = await fetch(fileUrl); + const blob = await response.blob(); + zip.file(fileName, blob); + })); + + // Generate and download zip file + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const url = window.URL.createObjectURL(zipBlob); + const a = document.createElement('a'); + a.href = url; + a.download = `${event.event_name || 'event'}_files.zip`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + // Reset button state + downloadButton.classList.remove('loading'); + downloadButton.removeAttribute('disabled'); + } + } catch (err) { + console.error('Failed to download files:', err); + alert('Failed to download files. Please try again.'); + + // Reset button state on error + if (downloadButton) { + downloadButton.classList.remove('loading'); + downloadButton.removeAttribute('disabled'); + } + } + }); + } + + // Remove modal when closed + modal.addEventListener("close", () => { + document.body.removeChild(modal); + }); + } catch (err) { + console.error("Failed to view files:", err); + } + } } \ No newline at end of file diff --git a/src/components/auth/EventCheckIn.ts b/src/components/auth/EventCheckIn.ts new file mode 100644 index 0000000..53f87b4 --- /dev/null +++ b/src/components/auth/EventCheckIn.ts @@ -0,0 +1,244 @@ +import PocketBase from "pocketbase"; +import yaml from "js-yaml"; +import configYaml from "../../data/storeConfig.yaml?raw"; + +// Configuration type definitions +interface Config { + api: { + baseUrl: string; + }; + ui: { + messages: { + event: { + checkIn: { + checking: string; + success: string; + error: string; + invalid: string; + expired: string; + alreadyCheckedIn: string; + messageTimeout: number; + }; + }; + }; + }; +} + +// Parse YAML configuration with type +const config = yaml.load(configYaml) as Config; + +interface AuthElements { + eventCodeInput: HTMLInputElement; + checkInButton: HTMLButtonElement; + checkInStatus: HTMLParagraphElement; +} + +export class EventCheckIn { + private pb: PocketBase; + private elements: AuthElements; + + constructor() { + this.pb = new PocketBase(config.api.baseUrl); + this.elements = this.getElements(); + + // Add event listener for the check-in button + this.elements.checkInButton.addEventListener("click", () => this.handleCheckIn()); + + // Add event listener for the enter key on the input field + this.elements.eventCodeInput.addEventListener("keypress", (event) => { + if (event.key === "Enter") { + this.handleCheckIn(); + } + }); + } + + private getElements(): AuthElements { + // Get both skeleton and content elements + const eventCodeInput = document.getElementById("eventCodeInput") as HTMLInputElement; + const checkInButton = document.getElementById("checkInButton") as HTMLButtonElement; + const checkInStatus = document.getElementById("checkInStatus") as HTMLParagraphElement; + + // Get skeleton elements + const skeletonEventCodeInput = document.getElementById("skeletonEventCodeInput") as HTMLInputElement; + const skeletonCheckInButton = document.getElementById("skeletonCheckInButton") as HTMLButtonElement; + const skeletonCheckInStatus = document.getElementById("skeletonCheckInStatus") as HTMLParagraphElement; + + // Check for required elements (only need one set) + if ((!eventCodeInput || !checkInButton || !checkInStatus) && + (!skeletonEventCodeInput || !skeletonCheckInButton || !skeletonCheckInStatus)) { + throw new Error("Required DOM elements not found"); + } + + // Return whichever set is available (prefer content over skeleton) + return { + eventCodeInput: eventCodeInput || skeletonEventCodeInput, + checkInButton: checkInButton || skeletonCheckInButton, + checkInStatus: checkInStatus || skeletonCheckInStatus, + }; + } + + private async validateEventCode(code: string): Promise<{ isValid: boolean; event?: any; message?: string }> { + try { + // Find event by code + const events = await this.pb.collection('events').getFullList({ + filter: `event_code = "${code}"`, + }); + + if (events.length === 0) { + return { isValid: false, message: "Invalid event code." }; + } + + const event = events[0]; + const now = new Date(); + const startDate = new Date(event.start_date); + const endDate = new Date(event.end_date); + + // Check if current time is within event window + if (now < startDate) { + return { + isValid: false, + message: `Event check-in is not open yet. Check-in opens at ${startDate.toLocaleString()}.` + }; + } + + if (now > endDate) { + return { + isValid: false, + message: `Event check-in has closed. Check-in closed at ${endDate.toLocaleString()}.` + }; + } + + return { isValid: true, event }; + } catch (err) { + console.error('Failed to validate event code:', err); + return { isValid: false, message: "Failed to validate event code. Please try again." }; + } + } + + private async handleCheckIn() { + const { eventCodeInput, checkInStatus } = this.elements; + const eventCode = eventCodeInput.value.trim(); + + if (!eventCode) { + this.showStatus("Please enter an event code", "error"); + return; + } + + try { + this.showStatus(config.ui.messages.event.checkIn.checking, "info"); + + // Get current user + const user = this.pb.authStore.model; + if (!user) { + this.showStatus("Please sign in to check in to events", "error"); + return; + } + + // Validate event code and check time window + const validation = await this.validateEventCode(eventCode); + if (!validation.isValid) { + this.showStatus(validation.message || "Invalid event code.", "error"); + return; + } + + const event = validation.event; + + // Get user's attended events and current points + const currentUser = await this.pb.collection("users").getOne(user.id); + let eventsAttended: string[] = []; + let currentPoints = currentUser.points || 0; + + // Handle different cases for events_attended field + if (currentUser.events_attended) { + if (Array.isArray(currentUser.events_attended)) { + eventsAttended = currentUser.events_attended; + } else if (typeof currentUser.events_attended === 'string') { + try { + eventsAttended = JSON.parse(currentUser.events_attended); + } catch (err) { + eventsAttended = []; + } + } + } + + // Ensure eventsAttended is an array + if (!Array.isArray(eventsAttended)) { + eventsAttended = []; + } + + // Check if already checked in using event_id + const isAlreadyCheckedIn = eventsAttended.includes(event.event_id); + + if (isAlreadyCheckedIn) { + this.showStatus(`You have already checked in to ${event.event_name}`, "info"); + eventCodeInput.value = ""; // Clear input + return; + } + + // Add event_id to user's attended events and update points + eventsAttended.push(event.event_id); + const pointsToAdd = event.points_to_reward || 0; + const newTotalPoints = currentPoints + pointsToAdd; + + // Update user with new events_attended and points + await this.pb.collection("users").update(user.id, { + events_attended: JSON.stringify(eventsAttended), + points: newTotalPoints + }); + + // Update event's attendees list + let eventAttendees = []; + try { + eventAttendees = JSON.parse(event.attendees || '[]'); + } catch (err) { + eventAttendees = []; + } + if (!Array.isArray(eventAttendees)) { + eventAttendees = []; + } + + // Add user to attendees if not already present + if (!eventAttendees.includes(user.id)) { + eventAttendees.push(user.id); + await this.pb.collection("events").update(event.id, { + attendees: JSON.stringify(eventAttendees) + }); + } + + // Show success message with points + this.showStatus( + `Successfully checked in to ${event.event_name}! You earned ${pointsToAdd} points!`, + "success" + ); + eventCodeInput.value = ""; // Clear input + } catch (err) { + console.error("Check-in error:", err); + this.showStatus(config.ui.messages.event.checkIn.error, "error"); + } + } + + private showStatus(message: string, type: "error" | "success" | "info") { + const { checkInStatus } = this.elements; + checkInStatus.textContent = message; + checkInStatus.className = "text-xs mt-1"; + + switch (type) { + case "error": + checkInStatus.classList.add("text-error"); + break; + case "success": + checkInStatus.classList.add("text-success"); + break; + case "info": + checkInStatus.classList.add("opacity-70"); + break; + } + + // Clear status after timeout + if (type !== "info") { + setTimeout(() => { + checkInStatus.textContent = ""; + }, config.ui.messages.event.checkIn.messageTimeout); + } + } +} \ No newline at end of file diff --git a/src/components/auth/StoreAuth.ts b/src/components/auth/StoreAuth.ts index 6a2cf78..2cf3e20 100644 --- a/src/components/auth/StoreAuth.ts +++ b/src/components/auth/StoreAuth.ts @@ -132,10 +132,10 @@ export class StoreAuth { private getElements(): AuthElements & { loadingSkeleton: HTMLDivElement } { // Fun typescript fixes const loginButton = document.getElementById( - "loginButton", + "contentLoginButton", ) as HTMLButtonElement; const logoutButton = document.getElementById( - "logoutButton", + "contentLogoutButton", ) as HTMLButtonElement; const userInfo = document.getElementById("userInfo") as HTMLDivElement; const loadingSkeleton = document.getElementById( @@ -380,14 +380,25 @@ export class StoreAuth { sponsorViewToggle, } = this.elements; - // Hide buttons initially - loginButton.style.display = "none"; - logoutButton.style.display = "none"; + // Get all login and logout buttons using classes + const allLoginButtons = document.querySelectorAll('.login-button'); + const allLogoutButtons = document.querySelectorAll('.logout-button'); + + // Hide all buttons initially + allLoginButtons.forEach(btn => btn.classList.add("hidden")); + allLogoutButtons.forEach(btn => btn.classList.add("hidden")); if (this.pb.authStore.isValid && this.pb.authStore.model) { + // Show logout buttons for authenticated users + allLogoutButtons.forEach(btn => btn.classList.remove("hidden")); + // Update all the user information first const user = this.pb.authStore.model; const isSponsor = user.member_type === this.config.roles.sponsor.name; + const isOfficer = [ + this.config.roles.officer.name, + this.config.roles.administrator.name + ].includes(user.member_type || ""); userName.textContent = user.name || this.config.ui.messages.auth.notProvided; userEmail.textContent = user.email || this.config.ui.messages.auth.notAvailable; @@ -454,12 +465,6 @@ export class StoreAuth { } // Handle view toggles visibility - const isOfficer = [ - this.config.roles.officer.name, - this.config.roles.administrator.name - ].includes(user.member_type || ""); - const isSponsor = user.member_type === this.config.roles.sponsor.name; - officerViewToggle.style.display = isOfficer ? "block" : "none"; sponsorViewToggle.style.display = isSponsor ? "block" : "none"; @@ -518,8 +523,12 @@ export class StoreAuth { userInfo.style.opacity = "1"; }, 50); - logoutButton.style.display = "block"; + officerViewToggle.style.display = isOfficer ? "block" : "none"; + sponsorViewToggle.style.display = isSponsor ? "block" : "none"; } else { + // Show login buttons for unauthenticated users + allLoginButtons.forEach(btn => btn.classList.remove("hidden")); + // Update for logged out state userName.textContent = this.config.ui.messages.auth.notSignedIn; userEmail.textContent = this.config.ui.messages.auth.notSignedIn; @@ -559,7 +568,6 @@ export class StoreAuth { userInfo.style.opacity = "1"; }, 50); - loginButton.style.display = "block"; officerViewToggle.style.display = "none"; sponsorViewToggle.style.display = "none"; } diff --git a/src/components/auth/UserProfile.astro b/src/components/auth/UserProfile.astro index 6082a52..880c0dc 100644 --- a/src/components/auth/UserProfile.astro +++ b/src/components/auth/UserProfile.astro @@ -47,20 +47,34 @@
- -
-
+ +
+
-
-
+
+
-
+
- + +
+
+
+
+
+
+
+
+
+
+
+
+ +
@@ -194,10 +208,14 @@
- -
@@ -347,3 +365,10 @@ background: rgba(0, 0, 0, 0.5); } + + diff --git a/src/components/profile/DefaultProfileView.astro b/src/components/profile/DefaultProfileView.astro new file mode 100644 index 0000000..91cf831 --- /dev/null +++ b/src/components/profile/DefaultProfileView.astro @@ -0,0 +1,439 @@ +
+ +
+
+ +
+
+

Events

+
+ + +
+
+
+
+
+
+
+
+
+ + + +
+
+ + +
+
+ + { + Array(3) + .fill(0) + .map(() => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )) + } +
+
+
+
+ + +
+
+

Something here

+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + diff --git a/src/components/profile/EventEditor.astro b/src/components/profile/EventEditor.astro new file mode 100644 index 0000000..a37159f --- /dev/null +++ b/src/components/profile/EventEditor.astro @@ -0,0 +1,270 @@ +--- +import yaml from "js-yaml"; +import configYaml from "../../data/storeConfig.yaml?raw"; + +const config = yaml.load(configYaml) as any; +const { editor_title, form } = config.ui.tables.events; +--- + + + + + + diff --git a/src/components/profile/EventManagement.astro b/src/components/profile/EventManagement.astro new file mode 100644 index 0000000..cad74f2 --- /dev/null +++ b/src/components/profile/EventManagement.astro @@ -0,0 +1,260 @@ +--- +import EventEditor from "./EventEditor.astro"; +import yaml from "js-yaml"; +import configYaml from "../../data/storeConfig.yaml?raw"; + +const config = yaml.load(configYaml) as any; +const { title, columns } = config.ui.tables.events; +--- + +
+

{title}

+
+
+ +
+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + diff --git a/src/components/store/MemberManagement.astro b/src/components/profile/MemberManagement.astro similarity index 93% rename from src/components/store/MemberManagement.astro rename to src/components/profile/MemberManagement.astro index ca2a9da..1e722cc 100644 --- a/src/components/store/MemberManagement.astro +++ b/src/components/profile/MemberManagement.astro @@ -49,7 +49,9 @@
- +
@@ -60,7 +62,7 @@ - +
diff --git a/src/components/store/OfficerStoreView.astro b/src/components/profile/OfficerView.astro similarity index 100% rename from src/components/store/OfficerStoreView.astro rename to src/components/profile/OfficerView.astro diff --git a/src/components/store/EventEditor.astro b/src/components/store/EventEditor.astro deleted file mode 100644 index 062574c..0000000 --- a/src/components/store/EventEditor.astro +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/src/components/store/EventManagement.astro b/src/components/store/EventManagement.astro deleted file mode 100644 index 11428b6..0000000 --- a/src/components/store/EventManagement.astro +++ /dev/null @@ -1,82 +0,0 @@ ---- -import EventEditor from "./EventEditor.astro"; ---- - -
-

Event Management

-
-
- -
-
- - -
-
-
-
- - - - - - - - - - - - - - - - -
-
- - - - diff --git a/src/data/storeConfig.yaml b/src/data/storeConfig.yaml index 3cf86d0..a431843 100644 --- a/src/data/storeConfig.yaml +++ b/src/data/storeConfig.yaml @@ -61,6 +61,14 @@ ui: deleteSuccess: Event deleted successfully! deleteError: Failed to delete event. Please try again. messageTimeout: 3000 + checkIn: + checking: Checking event code... + success: Successfully checked in to event! + error: Failed to check in. Please try again. + invalid: Invalid event code. Please try again. + expired: This event is not currently active. + alreadyCheckedIn: You have already checked in to this event. + messageTimeout: 3000 auth: loginError: Failed to start authentication @@ -70,6 +78,55 @@ ui: notAvailable: Not available never: Never + tables: + events: + title: Event Management + editor_title: Event Details + columns: + event_name: Event Name + event_id: Event ID + event_code: Event Code + start_date: Start Date + end_date: End Date + points_to_reward: Points to Reward + location: Location + registered_users: Registered + actions: Actions + form: + event_id: + label: Event ID + placeholder: Enter unique event ID + event_name: + label: Event Name + placeholder: Enter event name + event_code: + label: Event Code + placeholder: Enter check-in code + start_date: + date_label: Start Date for check-in + time_label: Start Time for check-in + date_placeholder: Select start date for check-in + time_placeholder: Select start time for check-in + end_date: + date_label: End Date for check-in + time_label: End Time for check-in + date_placeholder: Select end date for check-in + time_placeholder: Select end time for check-in + points_to_reward: + label: Points to Reward + placeholder: Enter points value + location: + label: Location + placeholder: Enter event location + files: + label: Event Files + help_text: Upload event-related files (PDF, DOC, DOCX, TXT, JPG, JPEG, PNG) + buttons: + save: Save + cancel: Cancel + edit: Edit + delete: Delete + defaults: pageSize: 50 sortField: -updated diff --git a/src/pages/online-store.astro b/src/pages/online-store.astro index 4d8f42f..de3db0e 100644 --- a/src/pages/online-store.astro +++ b/src/pages/online-store.astro @@ -2,8 +2,8 @@ import Layout from "../layouts/Layout.astro"; import UserProfile from "../components/auth/UserProfile.astro"; import DefaultStoreView from "../components/store/DefaultStoreView.astro"; -import OfficerStoreView from "../components/store/OfficerStoreView.astro"; -const title = "IEEE Store"; +import OfficerStoreView from "../components/profile/OfficerView.astro"; +const title = "IEEE Online Store"; --- diff --git a/src/pages/profile.astro b/src/pages/profile.astro new file mode 100644 index 0000000..78b2745 --- /dev/null +++ b/src/pages/profile.astro @@ -0,0 +1,50 @@ +--- +import Layout from "../layouts/Layout.astro"; +import UserProfile from "../components/auth/UserProfile.astro"; +import DefaultProfileView from "../components/profile/DefaultProfileView.astro"; +import OfficerProfileView from "../components/profile/OfficerView.astro"; +const title = "User Profile"; +--- + + +
+

Profile Management

+
+ +
+ +
+ + +
+
+ +
+ +
+
+
+
+ +