Compare commits
No commits in common. "main" and "mobile" have entirely different histories.
|
@ -1 +0,0 @@
|
|||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
3
.gitignore
vendored
|
@ -3,9 +3,6 @@ dist/
|
|||
|
||||
# generated types
|
||||
.astro/
|
||||
.cursor
|
||||
|
||||
final_review_gate.py
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
|
7
.vscode/settings.json
vendored
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.background": "#221489",
|
||||
"titleBar.activeBackground": "#301DC0",
|
||||
"titleBar.activeForeground": "#F9F9FE"
|
||||
}
|
||||
}
|
55
Dockerfile
|
@ -1,55 +0,0 @@
|
|||
# Use the official Bun image
|
||||
FROM oven/bun:1.1
|
||||
|
||||
# Install dependencies for Puppeteer and Chrome/Chromium
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
wget \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
chromium \
|
||||
gnupg \
|
||||
--no-install-recommends && \
|
||||
# Install Google Chrome stable
|
||||
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && \
|
||||
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y google-chrome-stable && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Puppeteer executable path (prefer google-chrome-stable, fallback to chromium)
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY bun.lock package.json ./
|
||||
RUN bun install
|
||||
|
||||
# Copy the rest of your app
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN bun run build
|
||||
|
||||
# Expose the port your app runs on (change if needed)
|
||||
EXPOSE 4321
|
||||
|
||||
# Start the server
|
||||
CMD ["bun", "run", "start"]
|
|
@ -15,29 +15,9 @@ import icon from "astro-icon";
|
|||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
|
||||
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
|
||||
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
|
||||
// Define environment variables that should be available to client components
|
||||
vite: {
|
||||
define: {
|
||||
"import.meta.env.LOGTO_APP_ID": JSON.stringify(process.env.LOGTO_APP_ID),
|
||||
"import.meta.env.LOGTO_APP_SECRET": JSON.stringify(
|
||||
process.env.LOGTO_APP_SECRET,
|
||||
),
|
||||
"import.meta.env.LOGTO_ENDPOINT": JSON.stringify(
|
||||
process.env.LOGTO_ENDPOINT,
|
||||
),
|
||||
"import.meta.env.LOGTO_TOKEN_ENDPOINT": JSON.stringify(
|
||||
process.env.LOGTO_TOKEN_ENDPOINT,
|
||||
),
|
||||
"import.meta.env.LOGTO_API_ENDPOINT": JSON.stringify(
|
||||
process.env.LOGTO_API_ENDPOINT,
|
||||
),
|
||||
},
|
||||
},
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
});
|
||||
|
|
BIN
bun.lockb
Normal file
|
@ -1,12 +0,0 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "4321:4321"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
|
||||
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
- PUPPETEER_DISABLE_SANDBOX=true
|
|
@ -1,4 +0,0 @@
|
|||
[phases.setup]
|
||||
nixPkgs = ["nodejs_20", "bun"]
|
||||
aptPkgs = ["curl", "wget"]
|
||||
|
207
notes.md
|
@ -1,207 +0,0 @@
|
|||
# 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
Normal file
53
package.json
|
@ -2,9 +2,6 @@
|
|||
"name": "ieeeucsd-dev",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=18.20.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
|
@ -13,49 +10,23 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.2.3",
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@astrojs/react": "^4.2.3",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@heroui/react": "^2.7.5",
|
||||
"@iconify-json/heroicons": "^1.2.2",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify/react": "^5.2.0",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/puppeteer": "^7.0.4",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.1",
|
||||
"astro": "^5.5.6",
|
||||
"astro-expressive-code": "^0.40.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"chart.js": "^4.4.7",
|
||||
"dexie": "^4.0.11",
|
||||
"framer-motion": "^12.6.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"@astrojs/mdx": "4.0.3",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "4.1.2",
|
||||
"@astrojs/tailwind": "5.1.4",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"astro": "5.1.1",
|
||||
"astro-expressive-code": "^0.38.3",
|
||||
"astro-icon": "^1.1.4",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.2",
|
||||
"pocketbase": "^0.25.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"puppeteer": "^24.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"rehype-expressive-code": "^0.40.2",
|
||||
"resend": "^4.5.1",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.8.3"
|
||||
"tailwindcss": "^3.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"daisyui": "^4.12.23",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"tailwindcss-animated": "^1.1.2",
|
||||
|
|
BIN
public/404.png
Normal file
After Width: | Height: | Size: 2 MiB |
BIN
public/404.webp
Before Width: | Height: | Size: 431 KiB |
BIN
public/calendar.png
Normal file
After Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 35 KiB |
BIN
public/halloween.png
Normal file
After Width: | Height: | Size: 203 KiB |
Before Width: | Height: | Size: 186 KiB |
BIN
public/hardhack.png
Normal file
After Width: | Height: | Size: 308 KiB |
Before Width: | Height: | Size: 183 KiB |
BIN
public/map.png
Normal file
After Width: | Height: | Size: 74 KiB |
BIN
public/map.webp
Before Width: | Height: | Size: 15 KiB |
BIN
public/officers/akhil.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 1,020 KiB |
BIN
public/officers/allie.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 987 KiB |
BIN
public/officers/andy.jpg
Normal file
After Width: | Height: | Size: 949 KiB |
Before Width: | Height: | Size: 741 KiB |
BIN
public/officers/anika.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.1 MiB |
BIN
public/officers/anu.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 1.4 MiB |
BIN
public/officers/ashlee.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
public/officers/charles.jpg
Normal file
After Width: | Height: | Size: 699 KiB |
Before Width: | Height: | Size: 540 KiB |
BIN
public/officers/christine.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
public/officers/dhruv.jpg
Normal file
After Width: | Height: | Size: 1.8 MiB |
Before Width: | Height: | Size: 1.6 MiB |
BIN
public/officers/dihan.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.3 MiB |
BIN
public/officers/emma.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 1.1 MiB |
BIN
public/officers/erik.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
public/officers/jonathan.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 1.1 MiB |
BIN
public/officers/lauren.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
public/officers/lisa.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 1.3 MiB |
BIN
public/officers/philip.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 1.2 MiB |
BIN
public/officers/pranav.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 943 KiB |
BIN
public/officers/rafaella.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 987 KiB |
BIN
public/officers/raymond.jpg
Normal file
After Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 1.5 MiB |
BIN
public/officers/ridhi.jpg
Normal file
After Width: | Height: | Size: 1 MiB |
Before Width: | Height: | Size: 973 KiB |
BIN
public/officers/rohil.jpg
Normal file
After Width: | Height: | Size: 2.1 MiB |
Before Width: | Height: | Size: 2.1 MiB |
BIN
public/officers/shing.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.3 MiB |
BIN
public/officers/shipra.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.3 MiB |
BIN
public/officers/stella.jpg
Normal file
After Width: | Height: | Size: 235 KiB |
Before Width: | Height: | Size: 209 KiB |
BIN
public/officers/steph.jpg
Normal file
After Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 74 KiB |
BIN
public/officers/terri.jpg
Normal file
After Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 1.5 MiB |
BIN
public/officers/zarif.png
Normal file
After Width: | Height: | Size: 205 KiB |
Before Width: | Height: | Size: 29 KiB |
BIN
public/project.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 67 KiB |
BIN
public/robocup.png
Normal file
After Width: | Height: | Size: 4.2 MiB |
Before Width: | Height: | Size: 122 KiB |
BIN
public/signal.png
Normal file
After Width: | Height: | Size: 216 KiB |
Before Width: | Height: | Size: 11 KiB |
BIN
public/supercomp.png
Normal file
After Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 42 KiB |
|
@ -1,166 +1,3 @@
|
|||
---
|
||||
const { filters, currentFilter } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="inline-flex border border-white/20 rounded-full md:p-[0.2vw] p-[0.4vw] relative my-[3vw]">
|
||||
<div
|
||||
id="slider"
|
||||
class="absolute h-[calc(100%-15%)] bg-[#FFB81C] rounded-full transition-none"
|
||||
style="left: 1%;"
|
||||
>
|
||||
</div>
|
||||
{
|
||||
filters.map((filter) => (
|
||||
<button
|
||||
data-filter={filter}
|
||||
class={`md:text-[1.3vw] text-[2.5vw] md:px-[1.8vw] px-[3vw] md:py-[0.2vw] py-[0.4vw] rounded-full transition-all relative z-10 ${
|
||||
currentFilter === filter
|
||||
? "text-black"
|
||||
: "text-white hover:bg-white/10 hover:bg-opacity-50"
|
||||
}`}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const buttons = document.querySelectorAll("[data-filter]");
|
||||
const officers = document.querySelectorAll("[data-officer]");
|
||||
const container = officers[0]?.parentElement;
|
||||
const slider = document.getElementById("slider");
|
||||
|
||||
// Define type order for consistent sorting
|
||||
const typeOrder = ["Executives", "Internal", "Events", "Projects"];
|
||||
|
||||
function getTypeWeight(type) {
|
||||
const index = typeOrder.indexOf(type);
|
||||
return index === -1 ? typeOrder.length : index;
|
||||
}
|
||||
|
||||
function sortOfficersByType() {
|
||||
const officerArray = Array.from(officers);
|
||||
officerArray.sort((a, b) => {
|
||||
const aTypes = JSON.parse(a.getAttribute("data-types"));
|
||||
const bTypes = JSON.parse(b.getAttribute("data-types"));
|
||||
return getTypeWeight(aTypes[0]) - getTypeWeight(bTypes[0]);
|
||||
});
|
||||
|
||||
officerArray.forEach((officer) => {
|
||||
container.appendChild(officer);
|
||||
});
|
||||
}
|
||||
|
||||
function moveSlider(button) {
|
||||
if (!slider) return;
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = button.parentElement.getBoundingClientRect();
|
||||
|
||||
slider.style.width = `${buttonRect.width}px`;
|
||||
slider.style.left = `${buttonRect.left - containerRect.left}px`;
|
||||
}
|
||||
|
||||
function updateFilter(selectedFilter, clickedButton) {
|
||||
// Update button styles
|
||||
buttons.forEach((btn) => {
|
||||
const isSelected =
|
||||
btn.getAttribute("data-filter") === selectedFilter;
|
||||
btn.classList.toggle("text-black", isSelected);
|
||||
btn.classList.toggle("text-white", !isSelected);
|
||||
btn.classList.toggle("hover:bg-white/10", !isSelected);
|
||||
btn.classList.toggle("hover:bg-opacity-50", !isSelected);
|
||||
});
|
||||
|
||||
// move slider
|
||||
moveSlider(clickedButton);
|
||||
|
||||
// fades out all officers
|
||||
officers.forEach((officer) => {
|
||||
officer.style.opacity = "0";
|
||||
officer.style.transition = "opacity 300ms ease-out";
|
||||
});
|
||||
|
||||
// waits, then removes and re-adds officers
|
||||
setTimeout(() => {
|
||||
// removes all officers from container
|
||||
officers.forEach((officer) => {
|
||||
officer.remove();
|
||||
});
|
||||
|
||||
// filters officers and prepares them for re-insertion
|
||||
const officersToShow = Array.from(officers).filter((officer) => {
|
||||
const types = JSON.parse(
|
||||
officer.getAttribute("data-types") || "[]"
|
||||
);
|
||||
return (
|
||||
selectedFilter === "All" || types.includes(selectedFilter)
|
||||
);
|
||||
});
|
||||
|
||||
// sorts if needed
|
||||
if (selectedFilter === "All") {
|
||||
officersToShow.sort((a, b) => {
|
||||
const aTypes = JSON.parse(a.getAttribute("data-types"));
|
||||
const bTypes = JSON.parse(b.getAttribute("data-types"));
|
||||
return getTypeWeight(aTypes[0]) - getTypeWeight(bTypes[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// sets initial opacity to 0 for fade in
|
||||
officersToShow.forEach((officer) => {
|
||||
officer.style.opacity = "0";
|
||||
officer.style.display = "";
|
||||
container.appendChild(officer);
|
||||
});
|
||||
|
||||
// triggers reflow and fades in
|
||||
requestAnimationFrame(() => {
|
||||
officersToShow.forEach((officer) => {
|
||||
officer.style.opacity = "1";
|
||||
});
|
||||
});
|
||||
}, 300); // matches fade-out duration
|
||||
}
|
||||
|
||||
sortOfficersByType();
|
||||
|
||||
// init
|
||||
const initialButton = Array.from(buttons).find(
|
||||
(btn) => btn.getAttribute("data-filter") === "All"
|
||||
);
|
||||
|
||||
// init
|
||||
if (initialButton && slider) {
|
||||
const buttonRect = initialButton.getBoundingClientRect();
|
||||
slider.style.width = `${buttonRect.width}px`;
|
||||
// turns on transitions
|
||||
requestAnimationFrame(() => {
|
||||
slider.classList.remove("transition-none");
|
||||
slider.classList.add(
|
||||
"transition-all",
|
||||
"duration-300",
|
||||
"ease-in-out"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// reveals officers after sorting with animation
|
||||
requestAnimationFrame(() => {
|
||||
officers.forEach((officer) => {
|
||||
officer.style.transition = "opacity 300ms ease-out";
|
||||
officer.style.visibility = "visible";
|
||||
// triggers reflow
|
||||
officer.offsetHeight;
|
||||
officer.style.opacity = "1";
|
||||
});
|
||||
});
|
||||
|
||||
// addss click handlers
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const filterValue = button.getAttribute("data-filter");
|
||||
updateFilter(filterValue, button);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
---
|
|
@ -2,55 +2,31 @@
|
|||
import { FaGear } from "react-icons/fa6";
|
||||
import { MdEmail } from "react-icons/md";
|
||||
import Link from "next/link";
|
||||
import { Image } from "astro:assets";
|
||||
const { name, position, picture, email } = Astro.props;
|
||||
const {name, position, picture, email} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="text-white">
|
||||
<div class="text-ieee-yellow">
|
||||
<Link
|
||||
href={`mailto:${email}`}
|
||||
className="flex items-center ml-[3%] py-[0.5vh] group"
|
||||
>
|
||||
<MdEmail
|
||||
className="md:text-[1.5vw] text-[2.5vw] mr-[0.5%] group-hover:scale-110 group-hover:opacity-70 duration-300"
|
||||
/>
|
||||
<p class="md:text-[0.8vw] text-[2vw]">
|
||||
<div class = "text-white">
|
||||
<div class = "text-ieee-yellow">
|
||||
<Link href={`mailto:${email}`} className = "flex items-center ml-[3%] py-[0.5vh] group">
|
||||
<MdEmail className = "md:text-[1.5vw] text-[2.5vw] mr-[0.5%] group-hover:scale-110 group-hover:opacity-70 duration-300"/>
|
||||
<p class = "md:text-[0.8vw] text-[2vw]">
|
||||
{email}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="md:w-[20vw] w-[35vw] aspect-[334/440] bg-gradient-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[10%] flex flex-col items-center relative"
|
||||
>
|
||||
<Image
|
||||
src={picture}
|
||||
alt="officer"
|
||||
class="md:w-[18vw] w-[31vw] md:rounded-[1.5vw] rounded-[3vw] mt-[5%] mb-[3%]"
|
||||
width={334}
|
||||
height={440}
|
||||
/>
|
||||
<div
|
||||
class="bg-white w-fit rounded-full aspect-square md:p-[0.4vw] p-[0.8vw] text-ieee-black md:text-[1.8vw] text-[3.5vw] absolute md:right-[1.5vw] md:top-[1.5vw] right-[2.5vw] top-[2.5vw]"
|
||||
>
|
||||
<FaGear />
|
||||
<div class = "md:w-[20vw] w-[35vw] aspect-[334/440] bg-gradient-to-t from-ieee-blue-100/5 to-ieee-blue-100/25 rounded-[10%] flex flex-col items-center relative">
|
||||
<img src ={picture} alt = "officer" class = "md:w-[18vw] w-[31vw] md:rounded-[1.5vw] rounded-[3vw] mt-[5%] mb-[3%]" >
|
||||
<div class = "bg-white w-fit rounded-full aspect-square md:p-[0.4vw] p-[0.8vw] text-ieee-black md:text-[1.8vw] text-[3.5vw] absolute md:right-[1.5vw] md:top-[1.5vw] right-[2.5vw] top-[2.5vw]">
|
||||
<FaGear/>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-[85%] justify-between"
|
||||
>
|
||||
<p
|
||||
data-inview
|
||||
class="opacity-0 in-view:animate-fade-right md:text-[1.8vw] text-[3.5vw] font-light md:leading-[2.5vw] leading-[5vw] md:w-[10vw]"
|
||||
>
|
||||
<div class = "w-full flex justify-between px-[7%]">
|
||||
<p data-inview class = " in-view:animate-fade-right md:text-[2vw] text-[3.5vw] font-light md:leading-[2.5vw] leading-[5vw]">
|
||||
{name}
|
||||
</p>
|
||||
<div
|
||||
data-inview
|
||||
class="md:mt-[0.5vw] mt-[1.5vw] in-view:animate-fade-up md:text-[0.8vw] text-[1.5vw] w-fit border-[0.11vw] border-white/90 rounded-full px-[1vw] py-[0.1vw] h-fit text-center"
|
||||
>
|
||||
<div data-inview class = "md:mt-[0.5vw] mt-[1.5vw] in-view:animate-fade-up md:text-[0.8vw] text-[1.5vw] md:w-[8vw] w-[15vw] border-[0.11vw] border-white/90 rounded-full p-[0.5%] h-fit text-center">
|
||||
{position}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
|
@ -1,64 +1,41 @@
|
|||
---
|
||||
import about from "../../images/about.webp";
|
||||
import about from "../../images/about.png";
|
||||
import { Image } from "astro:assets";
|
||||
import neko from "../../images/neko.webp";
|
||||
import neko from "../../images/neko.png";
|
||||
import { LiaDotCircle } from "react-icons/lia";
|
||||
import Officer from "../board/Officer.astro";
|
||||
import Filter from "../board/Filter.astro";
|
||||
import officers from "../../data/officers.json";
|
||||
|
||||
// Get all unique types and add 'All' option
|
||||
const typeOrder = ["Executives", "Internal", "Events", "Projects"];
|
||||
const types = ["All", ...typeOrder];
|
||||
|
||||
const currentFilter = "All";
|
||||
---
|
||||
|
||||
<div
|
||||
class="text-white flex flex-col items-center md:mt-[5vw] mt-[10vw] mb-[10vh]"
|
||||
>
|
||||
<div
|
||||
data-inview
|
||||
class="relative w-[40vw] md:w-[21vw] mb-[10vh] in-view:animate-fade-down"
|
||||
>
|
||||
<Image src={about} alt="About background image" />
|
||||
<Image
|
||||
src={neko}
|
||||
alt="About image"
|
||||
class="absolute top-[10%] left-[16%] aspect-[399/491] object-cover w-[27vw] md:w-[14vw] rounded-[2vw]"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-white flex flex-col items-center md:mt-[10vw] mt-[20vw] mb-[10vh]">
|
||||
<div data-inview class="relative w-[40vw] md:w-[21vw] in-view:animate-fade-down">
|
||||
<Image src={about} alt="About background image" />
|
||||
<Image
|
||||
src={neko}
|
||||
alt="About image"
|
||||
class="absolute top-[10%] left-[16%] aspect-[399/491] object-cover w-[27vw] md:w-[14vw] rounded-[2vw]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-[5vw] md:text-[2.5vw] flex items-center mt-[1vw]">
|
||||
<LiaDotCircle className="mr-[1vw] pt-[0.5%]" />
|
||||
<p>MEET THE BOARD</p>
|
||||
</div>
|
||||
<div class="text-[5vw] md:text-[2.5vw] flex items-center mt-[2vh]">
|
||||
<LiaDotCircle className="mr-[1vw] pt-[0.5%]" />
|
||||
<p>MEET THE BOARD</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="md:text-[1.3vw] text-[2.5vw] md:w-[56%] w-[70%] my-[3%] font-extralight text-center"
|
||||
>
|
||||
Our board comprises 31 students of varying majors, colleges, and interests!
|
||||
Feel free to reach out for any questions about our position or experiences.
|
||||
</p>
|
||||
<p class="md:text-[1.3vw] text-[2.5vw] md:w-[56%] w-[70%] my-[3%] font-extralight text-center">
|
||||
Our board comprises 31 students of varying majors, colleges, and interests! Feel free to reach out for any questions about our position or experiences.
|
||||
</p>
|
||||
|
||||
<Filter filters={types} currentFilter={currentFilter} />
|
||||
|
||||
<div class="grid gap-[3vw] md:grid-cols-3 grid-cols-2 mt-[2vh]">
|
||||
{
|
||||
officers.map((officer) => (
|
||||
<div
|
||||
data-officer
|
||||
data-types={JSON.stringify(officer.type)}
|
||||
style="opacity: 0; visibility: hidden"
|
||||
>
|
||||
<Officer
|
||||
name={officer.name}
|
||||
position={officer.position}
|
||||
picture={officer.picture}
|
||||
email={officer.email}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="grid gap-[3vw] md:grid-cols-3 grid-cols-2 mt-[10vh]">
|
||||
{
|
||||
officers.map((officer) => (
|
||||
<Officer
|
||||
name={officer.name}
|
||||
position={officer.position}
|
||||
picture={officer.picture}
|
||||
email={officer.email}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
---
|
||||
import { LiaDotCircle } from "react-icons/lia";
|
||||
const { title, text } = Astro.props;
|
||||
const {title, text} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col items-center text-white my-[10%]">
|
||||
<div class="flex items-center text-[4.5vw] md:text-[2.5vw] mb-[3%]">
|
||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||
<p
|
||||
class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center text-[2.5vw] mb-[3%]">
|
||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
||||
<p class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="w-[70%] md:text-[1.4vw] text-[2vw] font-light">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
<p class="w-[70%] text-[1.4vw] font-light ">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
|
@ -1,22 +1,16 @@
|
|||
<script>
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("in-view");
|
||||
entry.target.classList.remove("opacity-0");
|
||||
// console.log("Added 'in-view' class to:", entry.target);
|
||||
console.log("Added 'in-view' class to:", entry.target);
|
||||
} else {
|
||||
entry.target.classList.remove("in-view");
|
||||
entry.target.classList.add("opacity-0");
|
||||
// console.log("Removed 'in-view' class from:", entry.target);
|
||||
console.log("Removed 'in-view' class from:", entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll("[data-inview]")
|
||||
.forEach((el) => observer.observe(el));
|
||||
</script>
|
||||
}, { threshold: 0.2 });
|
||||
|
||||
document.querySelectorAll("[data-inview]").forEach((el) => observer.observe(el));
|
||||
</script>
|
||||
|
|
@ -4,385 +4,146 @@ import whiteLogoHorizontal from "../../images/logos/white_logo_horizontal.svg";
|
|||
import pages from "../../data/pages.json";
|
||||
---
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="flex justify-between items-center bg-ieee-black my-[1%] mx-[2.5%] py-[0.5%] px-[1%] rounded-full md:border-[0.1vw]"
|
||||
>
|
||||
<a href="/" class="hover:opacity-60 duration-300">
|
||||
<Image
|
||||
class="w-[15vw] md:block hidden"
|
||||
src={whiteLogoHorizontal}
|
||||
alt="IEEE UCSD Logo"
|
||||
/>
|
||||
<Image
|
||||
class="w-[40vw] md:hidden block"
|
||||
src={whiteLogoHorizontal}
|
||||
alt="IEEE UCSD Logo"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex md:w-[55%] md:justify-between">
|
||||
{
|
||||
pages.map((page) =>
|
||||
page.subpages ? (
|
||||
<div class="relative group">
|
||||
<a
|
||||
href={page.path}
|
||||
class="uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap text-white border-white hover:opacity-50 border-[0.1vw] font-light inline-block"
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-[12vw] top-full z-50">
|
||||
<div class="rounded-lg bg-ieee-black border border-white shadow-lg hidden group-hover:block animate-fade-down animate-duration-200 animate-ease-in-out">
|
||||
{page.subpages.map((subpage) => (
|
||||
<a
|
||||
href={subpage.path}
|
||||
class="font-light py-[4%] block text-white hover:bg-gray-700 text-[1.3vw] first:rounded-t-lg last:rounded-b-lg text-center whitespace-nowrap transition-colors duration-200"
|
||||
>
|
||||
{subpage.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={page.path}
|
||||
class={`uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap
|
||||
${
|
||||
page.name === "Dashboard"
|
||||
? "bg-ieee-yellow text-black hover:opacity-70"
|
||||
: "text-white border-white hover:opacity-50 border-[0.1vw] font-light"
|
||||
}`}
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
),
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Hamburger/Close Button -->
|
||||
<button
|
||||
id="menu-btn"
|
||||
class="md:hidden text-white p-2 flex justify-center items-center focus:outline-none relative z-[60] scale-150"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<!-- Hamburger Icon -->
|
||||
<svg
|
||||
class="w-6 h-6 menu-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<!-- Close Icon -->
|
||||
<svg
|
||||
class="w-6 h-6 close-icon hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="fixed inset-0 z-[51] hidden xl:hidden motion-safe:transition-transform motion-safe:duration-300 translate-x-full bg-black"
|
||||
>
|
||||
<div class="md:w-full w-fit fixed z-10">
|
||||
<div
|
||||
class="flex flex-col items-center min-h-screen w-full justify-center py-20 px-4 space-y-6 overflow-y-auto"
|
||||
class="flex justify-between items-center bg-black my-[1%] mx-[2.5%] py-[0.5%] px-[1%] md:rounded-full md:border-[0.1vw]"
|
||||
>
|
||||
{
|
||||
pages.map((page) =>
|
||||
page.subpages ? (
|
||||
<div class="w-full max-w-md space-y-4 relative dropdown-container">
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href={page.path}
|
||||
class="flex-1 block py-4 px-12 text-center rounded-[3rem] motion-safe:transition-colors motion-safe:duration-200 uppercase font-bold text-2xl text-white hover:text-gray-300 border-white border-2"
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
<button
|
||||
class="mobile-dropdown-toggle py-4 px-6 text-center rounded-[3rem] motion-safe:transition-all motion-safe:duration-200 uppercase font-bold text-2xl text-white hover:text-gray-300 border-white border-2 flex items-center justify-center"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 transform transition-transform duration-200 dropdown-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-dropdown-content mt-2 space-y-2 pl-4 absolute w-full animate-duration-200 animate-ease-in-out">
|
||||
{page.subpages.map((subpage) => (
|
||||
<a
|
||||
href={subpage.path}
|
||||
class="block py-3 px-8 text-center rounded-[2rem] motion-safe:transition-all motion-safe:duration-200 uppercase font-medium text-lg w-full text-white hover:text-gray-300 border-white border bg-[#111111] hover:bg-[#222222]"
|
||||
>
|
||||
{subpage.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={page.path}
|
||||
class={`block py-4 px-12 text-center rounded-[3rem] motion-safe:transition-colors motion-safe:duration-200 uppercase font-bold text-2xl w-full max-w-md
|
||||
${
|
||||
page.name === "Dashboard"
|
||||
? "bg-[#f3c135] text-black border-[#f3c135] hover:bg-[#dba923] hover:border-[#dba923]"
|
||||
: "text-white hover:text-gray-300 border-white border-2"
|
||||
}`}
|
||||
<a href="/" class="hover:opacity-60 duration-300 hidden md:flex">
|
||||
<Image
|
||||
class="w-[15vw]"
|
||||
src={whiteLogoHorizontal}
|
||||
alt="IEEE UCSD Logo"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex md:w-[55%] md:justify-between">
|
||||
{
|
||||
pages.map((page) => (
|
||||
<a
|
||||
href={page.path}
|
||||
class={`uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap
|
||||
${
|
||||
page.name === "Online Store"
|
||||
? "bg-ieee-yellow text-black hover:opacity-70"
|
||||
: "text-white border-white hover:opacity-50 border-[0.1vw] font-light"
|
||||
}`}
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Hamburger/Close Button -->
|
||||
<button
|
||||
id="menu-btn"
|
||||
class="md:hidden text-white p-2 flex justify-center items-center focus:outline-none relative z-[60] scale-150"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<!-- Hamburger Icon -->
|
||||
<svg
|
||||
class="w-6 h-6 menu-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
),
|
||||
)
|
||||
}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<!-- Close Icon -->
|
||||
<svg
|
||||
class="w-6 h-6 close-icon hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="fixed inset-0 z-[51] hidden xl:hidden motion-safe:transition-transform motion-safe:duration-300 translate-x-full"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center h-[70vh] justify-evenly bg-black"
|
||||
>
|
||||
{
|
||||
pages.map((page) => (
|
||||
<a
|
||||
href={page.path}
|
||||
class={`block py-2 px-8 text-center rounded-[3rem] motion-safe:transition-colors motion-safe:duration-200 uppercase font-bold text-xl
|
||||
${
|
||||
page.name === "Online Store"
|
||||
? "bg-[#f3c135] text-black border-[#f3c135] hover:bg-[#dba923] hover:border-[#dba923]"
|
||||
: "text-white hover:text-gray-300 border-white"
|
||||
}`}
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#mobile-menu.show {
|
||||
@apply translate-x-0;
|
||||
}
|
||||
|
||||
.mobile-dropdown-content {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
width: calc(100% - 1rem) !important;
|
||||
animation: fadeOut 0.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.mobile-dropdown-content.show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
backdrop-filter: none;
|
||||
animation: fadeIn 0.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
#mobile-menu.show {
|
||||
@apply translate-x-0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-dropdown-content a {
|
||||
margin-inline: auto;
|
||||
width: 100% !important;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.dropdown-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Add styles for focus effect */
|
||||
.dropdown-container {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.dropdown-container.active {
|
||||
transform: scale(1.02);
|
||||
z-index: 25;
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-container.active .mobile-dropdown-toggle,
|
||||
.dropdown-container.active > div > a {
|
||||
position: relative;
|
||||
z-index: 25;
|
||||
pointer-events: all;
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-container.active::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#mobile-menu.has-active-dropdown > div > *:not(.dropdown-container.active) {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.98);
|
||||
filter: blur(2px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#mobile-menu.has-active-dropdown .dropdown-container.active {
|
||||
pointer-events: all;
|
||||
filter: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#mobile-menu.has-active-dropdown
|
||||
.dropdown-container.active
|
||||
.mobile-dropdown-content.show {
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const menuBtn = document.getElementById("menu-btn");
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
const menuIcon = document.querySelector(".menu-icon");
|
||||
const closeIcon = document.querySelector(".close-icon");
|
||||
const dropdownToggles = document.querySelectorAll(".mobile-dropdown-toggle");
|
||||
const menuBtn = document.getElementById("menu-btn");
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
const menuIcon = document.querySelector(".menu-icon");
|
||||
const closeIcon = document.querySelector(".close-icon");
|
||||
|
||||
function toggleMenu(show: boolean) {
|
||||
if (show) {
|
||||
mobileMenu?.classList.remove("hidden");
|
||||
menuIcon?.classList.add("hidden");
|
||||
closeIcon?.classList.remove("hidden");
|
||||
document.body.style.overflow = "hidden";
|
||||
function toggleMenu(show: boolean) {
|
||||
if (show) {
|
||||
mobileMenu?.classList.remove("hidden");
|
||||
menuIcon?.classList.add("hidden");
|
||||
closeIcon?.classList.remove("hidden");
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
setTimeout(() => {
|
||||
mobileMenu?.classList.add("show");
|
||||
}, 10);
|
||||
} else {
|
||||
mobileMenu?.classList.remove("show");
|
||||
menuIcon?.classList.remove("hidden");
|
||||
closeIcon?.classList.add("hidden");
|
||||
document.body.style.overflow = "";
|
||||
setTimeout(() => {
|
||||
mobileMenu?.classList.add("show");
|
||||
}, 10);
|
||||
} else {
|
||||
mobileMenu?.classList.remove("show");
|
||||
menuIcon?.classList.remove("hidden");
|
||||
closeIcon?.classList.add("hidden");
|
||||
document.body.style.overflow = "";
|
||||
|
||||
// First wait for the navbar to slide out
|
||||
setTimeout(() => {
|
||||
mobileMenu?.classList.add("hidden");
|
||||
|
||||
// Then reset all dropdowns and focus states
|
||||
document.querySelectorAll(".dropdown-container").forEach((el) => {
|
||||
const dropdownContent = el.querySelector(".mobile-dropdown-content");
|
||||
const dropdownToggle = el.querySelector(".mobile-dropdown-toggle");
|
||||
const dropdownIcon = dropdownToggle?.querySelector(".dropdown-icon");
|
||||
|
||||
el.classList.remove("active");
|
||||
dropdownContent?.classList.remove("show");
|
||||
dropdownIcon?.classList.remove("rotated");
|
||||
dropdownToggle?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
mobileMenu?.classList.remove("has-active-dropdown");
|
||||
}, 300);
|
||||
setTimeout(() => {
|
||||
mobileMenu?.classList.add("hidden");
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menuBtn?.addEventListener("click", () => {
|
||||
const isMenuHidden = mobileMenu?.classList.contains("hidden") ?? true;
|
||||
toggleMenu(isMenuHidden);
|
||||
});
|
||||
|
||||
// Handle dropdown toggles
|
||||
dropdownToggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const container = toggle.closest(".dropdown-container");
|
||||
const content = toggle.parentElement?.nextElementSibling as HTMLElement;
|
||||
const icon = toggle.querySelector(".dropdown-icon");
|
||||
const isExpanded = toggle.getAttribute("aria-expanded") === "true";
|
||||
|
||||
// If clicking an already active dropdown, close it and restore interactions
|
||||
if (isExpanded) {
|
||||
// First close the dropdown
|
||||
content?.classList.remove("show");
|
||||
icon?.classList.remove("rotated");
|
||||
|
||||
// Then wait for animation to complete plus a delay before removing focus
|
||||
setTimeout(() => {
|
||||
container?.classList.remove("active");
|
||||
mobileMenu?.classList.remove("has-active-dropdown");
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
}, 300); // 200ms for animation + 100ms delay
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove active state from all dropdowns first
|
||||
document.querySelectorAll(".dropdown-container").forEach((el) => {
|
||||
const dropdownContent = el.querySelector(".mobile-dropdown-content");
|
||||
const dropdownToggle = el.querySelector(".mobile-dropdown-toggle");
|
||||
const dropdownIcon = dropdownToggle?.querySelector(".dropdown-icon");
|
||||
|
||||
dropdownContent?.classList.remove("show");
|
||||
dropdownIcon?.classList.remove("rotated");
|
||||
dropdownToggle?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
// First focus the new dropdown
|
||||
container?.classList.add("active");
|
||||
mobileMenu?.classList.add("has-active-dropdown");
|
||||
toggle.setAttribute("aria-expanded", "true");
|
||||
icon?.classList.add("rotated");
|
||||
|
||||
// Then show the content with animation after a short delay
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
content?.classList.add("show");
|
||||
});
|
||||
}, 100);
|
||||
menuBtn?.addEventListener("click", () => {
|
||||
const isMenuHidden = mobileMenu?.classList.contains("hidden") ?? true;
|
||||
toggleMenu(isMenuHidden);
|
||||
});
|
||||
});
|
||||
|
||||
// Close menu when clicking outside ~ Doesnt really work at the moment
|
||||
document.addEventListener("click", (e) => {
|
||||
if (
|
||||
!mobileMenu?.contains(e.target as Node) &&
|
||||
!menuBtn?.contains(e.target as Node) &&
|
||||
!mobileMenu?.classList.contains("hidden")
|
||||
) {
|
||||
toggleMenu(false);
|
||||
}
|
||||
});
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener("click", (e) => {
|
||||
if (
|
||||
!mobileMenu?.contains(e.target as Node) &&
|
||||
!menuBtn?.contains(e.target as Node) &&
|
||||
!mobileMenu?.classList.contains("hidden")
|
||||
) {
|
||||
toggleMenu(false);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -8,26 +8,20 @@ import { MdEmail } from "react-icons/md";
|
|||
import Link from "next/link";
|
||||
---
|
||||
|
||||
<div
|
||||
class="text-white flex flex-col items-center md:h-[35vw] h-[60vw] justify-between"
|
||||
>
|
||||
<div class="text-white flex flex-col items-center md:h-[35vw] h-[60vw] justify-between">
|
||||
<div class="flex items-center text-[4.5vw] md:text-[2.5vw]">
|
||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||
<p>Social Media</p>
|
||||
</div>
|
||||
|
||||
<p class="md:w-1/3 w-3/5 text-center text-[2vw] md:text-[1.2vw] pb-[5%]">
|
||||
Stay connected with us on Discord, Facebook, and Instagram! We regularly
|
||||
post information on upcoming events and competitions.
|
||||
Stay connected with us on Discord, Facebook, and Instagram! We regularly post information on upcoming events and competitions.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="md:w-[85%] w-full rounded-[3vw] bg-gradient-to-r from-ieee-blue-300 to-ieee-blue-100 relative md:h-[15vw] h-[30vw] flex items-center text-white/90"
|
||||
>
|
||||
<div
|
||||
data-inview
|
||||
class="md:w-2/5 w-1/2 flex justify-evenly ml-[5%] animate-ease-in-out in-view:animate-flip-up animate-duration-1000"
|
||||
>
|
||||
<div data-inview class="md:w-2/5 w-1/2 flex justify-evenly ml-[5%] animate-ease-in-out in-view:animate-flip-up animate-duration-1000">
|
||||
<Link
|
||||
href="https://www.instagram.com/ieee.ucsd"
|
||||
target="_blank"
|
||||
|
@ -38,11 +32,9 @@ import Link from "next/link";
|
|||
>
|
||||
<RiInstagramFill />
|
||||
</div>
|
||||
<p class="text-[2vw] md:text-[1.3vw] font-semibold">
|
||||
Instagram
|
||||
</p>
|
||||
<p class="text-[2vw] md:text-[1.3vw] font-semibold">Instagram</p>
|
||||
</Link>
|
||||
|
||||
|
||||
<Link
|
||||
href="https://discord.gg/XxfjqZSjca"
|
||||
target="_blank"
|
||||
|
@ -72,8 +64,8 @@ import Link from "next/link";
|
|||
<Image
|
||||
data-inview
|
||||
src={jellyfish}
|
||||
alt="Jellyfish mascot"
|
||||
class="absolute bottom-[2vw] md:w-[25vw] w-[35vw] right-[4vw] animate-wiggle animate-infinite"
|
||||
alt="cat placeholder"
|
||||
class="absolute bottom-0 md:w-[25vw] w-[35vw] right-[4vw] animate-wiggle animate-infinite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
const { title } = Astro.props;
|
||||
const {title} = Astro.props;
|
||||
import { LiaDotCircle } from "react-icons/lia";
|
||||
---
|
||||
|
||||
<div class="flex items-center md:text-[2.5vw] text-[4vw] mb-[5%]">
|
||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||
<p>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center text-[2.5vw] mb-[5%]">
|
||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
||||
<p>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
|
@ -1,13 +1,11 @@
|
|||
---
|
||||
const { title } = Astro.props;
|
||||
const {title} = Astro.props;
|
||||
import { LiaDotCircle } from "react-icons/lia";
|
||||
---
|
||||
|
||||
<div
|
||||
class="flex items-center md:text-[3vw] text-[4.5vw] ml-[10%] md:pt-[5%] pt-[10%] text-white font-semibold"
|
||||
>
|
||||
<LiaDotCircle className=" mr-[1vw] text-[2.7vw]" />
|
||||
<p>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center text-[3vw] ml-[10%] pt-[10%] text-white font-semibold">
|
||||
<LiaDotCircle className=" mr-[1vw] text-[2.7vw]"/>
|
||||
<p>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
|
@ -1,3 +0,0 @@
|
|||
---
|
||||
|
||||
---
|
|
@ -1,533 +0,0 @@
|
|||
---
|
||||
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>
|
|
@ -1,567 +0,0 @@
|
|||
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, LimitedUser } 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'
|
||||
event_type: string; // Add event_type field from schema
|
||||
}
|
||||
|
||||
// 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
|
||||
try {
|
||||
await dataSync.storeEventCode(eventCode);
|
||||
} catch (syncError) {
|
||||
// Log the error but don't show a toast to the user
|
||||
console.error("Error storing event code locally:", syncError);
|
||||
}
|
||||
|
||||
// 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 the food you ate (or will eat) at the event!</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);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Update the LimitedUser record with the new points total
|
||||
try {
|
||||
// Try to get the LimitedUser record to check if it exists
|
||||
let limitedUserExists = false;
|
||||
try {
|
||||
const limitedUser = await get.getOne(Collections.LIMITED_USERS, userId);
|
||||
limitedUserExists = !!limitedUser;
|
||||
} catch (e) {
|
||||
// Record doesn't exist
|
||||
limitedUserExists = false;
|
||||
}
|
||||
|
||||
// Create or update the LimitedUser record
|
||||
if (limitedUserExists) {
|
||||
await update.updateFields(Collections.LIMITED_USERS, userId, {
|
||||
points: JSON.stringify(totalPoints),
|
||||
total_events_attended: JSON.stringify(userAttendance.totalItems)
|
||||
});
|
||||
} else {
|
||||
// Get user data to create LimitedUser record
|
||||
const userData = await get.getOne(Collections.USERS, userId);
|
||||
if (userData) {
|
||||
await update.create(Collections.LIMITED_USERS, {
|
||||
id: userId, // Use same ID as user record
|
||||
name: userData.name || 'Anonymous User',
|
||||
major: userData.major || '',
|
||||
points: JSON.stringify(totalPoints),
|
||||
total_events_attended: JSON.stringify(userAttendance.totalItems)
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update LimitedUser record:', error);
|
||||
}
|
||||
|
||||
// Ensure local data is in sync with backend
|
||||
// First sync the new attendance record
|
||||
try {
|
||||
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
||||
|
||||
// Then sync the updated user and LimitedUser data
|
||||
await dataSync.syncCollection(Collections.USERS);
|
||||
await dataSync.syncCollection(Collections.LIMITED_USERS);
|
||||
} catch (syncError) {
|
||||
// Log the error but don't show a toast to the user
|
||||
console.error('Local sync failed:', syncError);
|
||||
}
|
||||
|
||||
// Clear event code from local storage
|
||||
try {
|
||||
await dataSync.clearEventCode();
|
||||
} catch (clearError) {
|
||||
// Log the error but don't show a toast to the user
|
||||
console.error("Error clearing event code from local storage:", clearError);
|
||||
}
|
||||
|
||||
// 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 ate (or will eat):</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-control">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter the food you will or are eating"
|
||||
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;
|
|
@ -1,675 +0,0 @@
|
|||
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'
|
||||
event_type: string; // Add event_type field from schema
|
||||
}
|
||||
|
||||
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);
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleDescription = (eventId: string) => {
|
||||
setExpandedDescriptions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(eventId)) {
|
||||
newSet.delete(eventId);
|
||||
} else {
|
||||
newSet.add(eventId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 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>(
|
||||
Collections.EVENT_ATTENDEES,
|
||||
1,
|
||||
1,
|
||||
`user="${currentUser.id}" && event="${event.id}"`
|
||||
);
|
||||
|
||||
const hasAttendedEvent = attendees.totalItems > 0;
|
||||
|
||||
// Store the attendance status in the window object with the event
|
||||
const eventDataId = `event_${event.id}`;
|
||||
if (window[eventDataId]) {
|
||||
window[eventDataId].hasAttended = hasAttendedEvent;
|
||||
}
|
||||
|
||||
// Update the card UI based on attendance status
|
||||
const cardElement = document.getElementById(`event-card-${event.id}`);
|
||||
if (cardElement) {
|
||||
const attendedBadge = document.getElementById(`attendance-badge-${event.id}`);
|
||||
if (attendedBadge && hasAttendedEvent) {
|
||||
attendedBadge.classList.remove('badge-ghost');
|
||||
attendedBadge.classList.add('badge-success');
|
||||
|
||||
// Update the icon and text
|
||||
const icon = attendedBadge.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.setAttribute('icon', 'heroicons:check-circle');
|
||||
}
|
||||
|
||||
// Update the text content
|
||||
attendedBadge.textContent = '';
|
||||
|
||||
// Recreate the icon
|
||||
const iconElement = document.createElement('span');
|
||||
iconElement.className = 'h-3 w-3';
|
||||
iconElement.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10zm-.997-6l7.07-7.071l-1.414-1.414l-5.656 5.657l-2.829-2.829l-1.414 1.414L11.003 16z"/></svg>';
|
||||
attendedBadge.appendChild(iconElement);
|
||||
|
||||
// Add the text
|
||||
const textNode = document.createTextNode(' Attended');
|
||||
attendedBadge.appendChild(textNode);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
const isExpanded = expandedDescriptions.has(event.id);
|
||||
const description = event.event_description || "No description available";
|
||||
|
||||
return (
|
||||
<div id={`event-card-${event.id}`} 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-4">
|
||||
{/* Event Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
|
||||
<div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
|
||||
</div>
|
||||
|
||||
{/* Event Description */}
|
||||
<div className="mb-3">
|
||||
<p className={`text-xs sm:text-sm text-base-content/70 ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
{description}
|
||||
</p>
|
||||
{description.length > 80 && (
|
||||
<button
|
||||
onClick={() => toggleDescription(event.id)}
|
||||
className="text-xs text-primary hover:text-primary-focus mt-1 flex items-center"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<Icon icon="heroicons:chevron-up" className="h-3 w-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="heroicons:chevron-down" className="h-3 w-3 mr-1" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="grid grid-cols-1 gap-1 mb-3 text-xs sm:text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="heroicons:calendar" className="h-3.5 w-3.5 text-primary" />
|
||||
<span>
|
||||
{startDate.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="heroicons:clock" className="h-3.5 w-3.5 text-primary" />
|
||||
<span>
|
||||
{startDate.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="heroicons:map-pin" className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="line-clamp-1">{event.location || "No location specified"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="heroicons:tag" className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="line-clamp-1 capitalize">{event.event_type || "Other"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-auto">
|
||||
{event.files && event.files.length > 0 && (
|
||||
<button
|
||||
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
|
||||
className="btn btn-accent btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-3 shadow-md hover:shadow-lg transition-all duration-300 rounded-full"
|
||||
>
|
||||
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
Files ({event.files.length})
|
||||
</button>
|
||||
)}
|
||||
{isPastEvent && (
|
||||
<div id={`attendance-badge-${event.id}`} className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1 ml-auto`}>
|
||||
<Icon
|
||||
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{hasAttended ? 'Attended' : 'Not Attended'}
|
||||
</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()) {
|
||||
// Silently return without error when on dashboard page
|
||||
if (window.location.pathname.includes('/dashboard')) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
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;
|
|
@ -1,34 +0,0 @@
|
|||
---
|
||||
import LeaderboardTable from "./LeaderboardSection/LeaderboardTable";
|
||||
import LeaderboardStats from "./LeaderboardSection/LeaderboardStats";
|
||||
---
|
||||
|
||||
<div class="grid gap-6">
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-6 h-6 text-[#f6b93b]"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 00-.584.859 6.753 6.753 0 006.138 5.6 6.73 6.73 0 002.743 1.346A6.707 6.707 0 019.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 00-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 00.75-.75 2.25 2.25 0 00-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 01-1.112-3.173 6.73 6.73 0 002.743-1.347 6.753 6.753 0 006.139-5.6.75.75 0 00-.585-.858 47.077 47.077 0 00-3.07-.543V2.62a.75.75 0 00-.658-.744 49.22 49.22 0 00-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 00-.657.744zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 013.16 5.337a45.6 45.6 0 012.006-.343v.256zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 01-2.863 3.207 6.72 6.72 0 00.857-3.294z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Leaderboard
|
||||
</h2>
|
||||
<div class="divider mt-0 mb-4"></div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="mb-6">
|
||||
<LeaderboardStats client:load />
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard table -->
|
||||
<LeaderboardTable client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,189 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import type { LimitedUser } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
interface LeaderboardStats {
|
||||
totalUsers: number;
|
||||
totalPoints: number;
|
||||
topScore: number;
|
||||
yourPoints: number;
|
||||
yourRank: number | null;
|
||||
}
|
||||
|
||||
export default function LeaderboardStats() {
|
||||
const [stats, setStats] = useState<LeaderboardStats>({
|
||||
totalUsers: 0,
|
||||
totalPoints: 0,
|
||||
topScore: 0,
|
||||
yourPoints: 0,
|
||||
yourRank: null
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Set the current user ID once on component mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Use the Authentication class directly
|
||||
const isLoggedIn = auth.isAuthenticated();
|
||||
setIsAuthenticated(isLoggedIn);
|
||||
|
||||
if (isLoggedIn) {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user && user.id) {
|
||||
setCurrentUserId(user.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking authentication:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Get all users without sorting - we'll sort on client side
|
||||
const response = await get.getList(Collections.LIMITED_USERS, 1, 500, '', '', {
|
||||
fields: ['id', 'name', 'points']
|
||||
});
|
||||
|
||||
// Parse points from JSON string and convert to number
|
||||
const processedUsers = response.items.map((user: Partial<LimitedUser>) => {
|
||||
let pointsValue = 0;
|
||||
try {
|
||||
if (user.points) {
|
||||
// Parse the JSON string to get the points value
|
||||
const pointsData = JSON.parse(user.points);
|
||||
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing points data:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
parsedPoints: pointsValue
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out users with no points for the leaderboard stats
|
||||
const leaderboardUsers = processedUsers
|
||||
.filter(user => user.parsedPoints > 0)
|
||||
// Sort by points descending
|
||||
.sort((a, b) => b.parsedPoints - a.parsedPoints);
|
||||
|
||||
const totalUsers = leaderboardUsers.length;
|
||||
const totalPoints = leaderboardUsers.reduce((sum: number, user) => sum + user.parsedPoints, 0);
|
||||
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].parsedPoints : 0;
|
||||
|
||||
// Find current user's points and rank - BUT don't filter by points > 0 for the current user
|
||||
let yourPoints = 0;
|
||||
let yourRank = null;
|
||||
|
||||
if (isAuthenticated && currentUserId) {
|
||||
// Look for the current user in ALL processed users, not just those with points > 0
|
||||
const currentUser = processedUsers.find(user => user.id === currentUserId);
|
||||
|
||||
if (currentUser) {
|
||||
yourPoints = currentUser.parsedPoints || 0;
|
||||
|
||||
// Only calculate rank if user has points
|
||||
if (yourPoints > 0) {
|
||||
// Find user position in the sorted array
|
||||
for (let i = 0; i < leaderboardUsers.length; i++) {
|
||||
if (leaderboardUsers[i].id === currentUserId) {
|
||||
yourRank = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalUsers,
|
||||
totalPoints,
|
||||
topScore,
|
||||
yourPoints,
|
||||
yourRank
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching leaderboard stats:', err);
|
||||
// Set fallback stats
|
||||
setStats({
|
||||
totalUsers: 0,
|
||||
totalPoints: 0,
|
||||
topScore: 0,
|
||||
yourPoints: 0,
|
||||
yourRank: null
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [get, isAuthenticated, currentUserId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-24 bg-gray-100/50 dark:bg-gray-800/50 animate-pulse rounded-xl">
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
|
||||
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Members</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Points</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">{stats.topScore}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{isAuthenticated ? stats.yourPoints : '-'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{isAuthenticated
|
||||
? (stats.yourRank ? `Ranked #${stats.yourRank}` : 'Not ranked yet')
|
||||
: 'Log in to see your rank'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,362 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import type { User, LimitedUser } from '../../../schemas/pocketbase/schema';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
interface LeaderboardUser {
|
||||
id: string;
|
||||
name: string;
|
||||
points: number;
|
||||
avatar?: string;
|
||||
major?: string;
|
||||
}
|
||||
|
||||
// Trophy icon SVG for the rankings
|
||||
const TrophyIcon = ({ className }: { className: string }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path fillRule="evenodd" d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 00-.584.859 6.753 6.753 0 006.138 5.6 6.73 6.73 0 002.743 1.346A6.707 6.707 0 019.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 00-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 00.75-.75 2.25 2.25 0 00-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 01-1.112-3.173 6.73 6.73 0 002.743-1.347 6.753 6.753 0 006.139-5.6.75.75 0 00-.585-.858 47.077 47.077 0 00-3.07-.543V2.62a.75.75 0 00-.658-.744 49.22 49.22 0 00-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 00-.657.744zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 013.16 5.337a45.6 45.6 0 012.006-.343v.256zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 01-2.863 3.207 6.72 6.72 0 00.857-3.294z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function LeaderboardTable() {
|
||||
const [users, setUsers] = useState<LeaderboardUser[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<LeaderboardUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUserRank, setCurrentUserRank] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const usersPerPage = 10;
|
||||
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Set the current user ID once on component mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Use the Authentication class directly
|
||||
const isLoggedIn = auth.isAuthenticated();
|
||||
setIsAuthenticated(isLoggedIn);
|
||||
|
||||
if (isLoggedIn) {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user && user.id) {
|
||||
setCurrentUserId(user.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking authentication:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLeaderboard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch users without sorting - we'll sort on client side
|
||||
const response = await get.getList(Collections.LIMITED_USERS, 1, 100, '', '', {
|
||||
fields: ['id', 'name', 'points', 'avatar', 'major']
|
||||
});
|
||||
|
||||
// First get the current user separately so we can include them even if they have 0 points
|
||||
let currentUserData = null;
|
||||
if (isAuthenticated && currentUserId) {
|
||||
currentUserData = response.items.find((user: Partial<LimitedUser>) => user.id === currentUserId);
|
||||
}
|
||||
|
||||
// Parse points from JSON string and convert to number
|
||||
const processedUsers = response.items.map((user: any) => {
|
||||
let pointsValue = 0;
|
||||
try {
|
||||
if (user.points) {
|
||||
// Parse the JSON string to get the points value
|
||||
const pointsData = JSON.parse(user.points);
|
||||
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing points data:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
major: user.major,
|
||||
avatar: user.avatar, // Include avatar if it exists
|
||||
points: user.points,
|
||||
parsedPoints: pointsValue
|
||||
};
|
||||
});
|
||||
|
||||
// Filter and map to our leaderboard user format, and sort client-side
|
||||
let leaderboardUsers = processedUsers
|
||||
.filter(user => user.parsedPoints > 0)
|
||||
.sort((a, b) => (b.parsedPoints || 0) - (a.parsedPoints || 0))
|
||||
.map((user, index: number) => {
|
||||
// Check if this is the current user
|
||||
if (isAuthenticated && user.id === currentUserId) {
|
||||
setCurrentUserRank(index + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id || '',
|
||||
name: user.name || 'Anonymous User',
|
||||
points: user.parsedPoints,
|
||||
avatar: user.avatar,
|
||||
major: user.major
|
||||
};
|
||||
});
|
||||
|
||||
// Include current user even if they have 0 points,
|
||||
// but don't include in ranking if they have no points
|
||||
if (isAuthenticated && currentUserId) {
|
||||
// Find current user in processed users
|
||||
const currentUserProcessed = processedUsers.find(user => user.id === currentUserId);
|
||||
|
||||
// If current user exists and isn't already in the leaderboard (has 0 points)
|
||||
if (currentUserProcessed && !leaderboardUsers.some(user => user.id === currentUserId)) {
|
||||
leaderboardUsers.push({
|
||||
id: currentUserProcessed.id || '',
|
||||
name: currentUserProcessed.name || 'Anonymous User',
|
||||
points: currentUserProcessed.parsedPoints || 0,
|
||||
avatar: currentUserProcessed.avatar,
|
||||
major: currentUserProcessed.major
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setUsers(leaderboardUsers);
|
||||
setFilteredUsers(leaderboardUsers);
|
||||
} catch (err) {
|
||||
console.error('Error fetching leaderboard:', err);
|
||||
setError('Failed to load leaderboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLeaderboard();
|
||||
}, [get, isAuthenticated, currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === '') {
|
||||
setFilteredUsers(users);
|
||||
setCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = users.filter(user =>
|
||||
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(user.major && user.major.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, users]);
|
||||
|
||||
// Get current users for pagination
|
||||
const indexOfLastUser = currentPage * usersPerPage;
|
||||
const indexOfFirstUser = indexOfLastUser - usersPerPage;
|
||||
const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
|
||||
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
|
||||
|
||||
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<div className="w-10 h-10 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-600 dark:text-red-400 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-gray-600 dark:text-gray-300">No users with points found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or major..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
|
||||
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
|
||||
focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50/80 dark:bg-gray-800/80">
|
||||
<tr>
|
||||
<th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
Rank
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white/90 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{currentUsers.map((user, index) => {
|
||||
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
|
||||
const isCurrentUser = user.id === currentUserId;
|
||||
|
||||
return (
|
||||
<tr key={user.id} className={isCurrentUser ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
{actualRank ? (
|
||||
actualRank <= 3 ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8">
|
||||
{actualRank === 1 && <TrophyIcon className="text-yellow-500 w-6 h-6" />}
|
||||
{actualRank === 2 && <TrophyIcon className="text-gray-400 w-6 h-6" />}
|
||||
{actualRank === 3 && <TrophyIcon className="text-amber-700 w-6 h-6" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">{actualRank}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Not Ranked</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
|
||||
{user.avatar ? (
|
||||
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-lg font-bold text-gray-700 dark:text-gray-300">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||
{user.name}
|
||||
</div>
|
||||
{user.major && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.major}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{user.points}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<nav className="flex items-center">
|
||||
<button
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-700
|
||||
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
onClick={() => paginate(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<button
|
||||
key={i + 1}
|
||||
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-700
|
||||
bg-white/90 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
|
||||
? 'text-indigo-600 dark:text-indigo-400 border-indigo-500 dark:border-indigo-500 z-10'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={() => paginate(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-700
|
||||
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show current user rank if not in current page */}
|
||||
{isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && (
|
||||
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Your rank: <span className="font-bold text-indigo-600 dark:text-indigo-400">#{currentUserRank}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current user with 0 points */}
|
||||
{isAuthenticated && currentUserId &&
|
||||
!currentUserRank &&
|
||||
currentUsers.some(user => user.id === currentUserId) && (
|
||||
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Participate in events to earn points and get ranked!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|