Compare commits

..

No commits in common. "main" and "mobile" have entirely different histories.
main ... mobile

320 changed files with 10476 additions and 47215 deletions

View file

@ -1 +0,0 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

3
.gitignore vendored
View file

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

View file

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

View file

@ -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"]

View file

@ -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",
}),
});

2250
bun.lock

File diff suppressed because it is too large Load diff

BIN
bun.lockb Normal file

Binary file not shown.

View 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

View file

@ -1,4 +0,0 @@
[phases.setup]
nixPkgs = ["nodejs_20", "bun"]
aptPkgs = ["curl", "wget"]

207
notes.md
View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

BIN
public/calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

BIN
public/halloween.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

BIN
public/hardhack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

BIN
public/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
public/officers/akhil.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,020 KiB

BIN
public/officers/allie.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 KiB

BIN
public/officers/andy.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

BIN
public/officers/anika.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/officers/anu.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

BIN
public/officers/ashlee.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/officers/charles.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/officers/dhruv.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

BIN
public/officers/dihan.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/officers/emma.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/officers/erik.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/officers/lauren.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/officers/lisa.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/officers/philip.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/officers/pranav.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 KiB

BIN
public/officers/raymond.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/officers/ridhi.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 KiB

BIN
public/officers/rohil.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

BIN
public/officers/shing.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/officers/shipra.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/officers/stella.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

BIN
public/officers/steph.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

BIN
public/officers/terri.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/officers/zarif.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

BIN
public/project.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
public/robocup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

BIN
public/signal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
public/supercomp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View file

@ -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>
---

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show more