From 1dc4f56425209d4ce1d7bb78ec8b5e7b5a755a82 Mon Sep 17 00:00:00 2001 From: Anjana Vakil Date: Tue, 26 Aug 2025 12:40:16 -0500 Subject: reset --- frontend/src/components/Events.js | 106 ++++++++++++++++++++++++++++++++++++++ frontend/src/components/Footer.js | 10 ++++ frontend/src/components/Forms.js | 67 ++++++++++++++++++++++++ frontend/src/components/Header.js | 33 ++++++++++++ frontend/src/components/Icons.js | 19 +++++++ frontend/src/components/Main.js | 9 ++++ frontend/src/components/Modal.js | 80 ++++++++++++++++++++++++++++ frontend/src/icons/calendar.svg | 4 ++ frontend/src/icons/heart.svg | 4 ++ frontend/src/icons/pico.svg | 21 ++++++++ frontend/src/icons/sun-moon.svg | 4 ++ frontend/src/icons/typescript.svg | 1 + frontend/src/icons/vite.svg | 1 + frontend/src/main.js | 26 ++++++++++ frontend/src/style.css | 81 +++++++++++++++++++++++++++++ 15 files changed, 466 insertions(+) create mode 100644 frontend/src/components/Events.js create mode 100644 frontend/src/components/Footer.js create mode 100644 frontend/src/components/Forms.js create mode 100644 frontend/src/components/Header.js create mode 100644 frontend/src/components/Icons.js create mode 100644 frontend/src/components/Main.js create mode 100644 frontend/src/components/Modal.js create mode 100644 frontend/src/icons/calendar.svg create mode 100644 frontend/src/icons/heart.svg create mode 100644 frontend/src/icons/pico.svg create mode 100644 frontend/src/icons/sun-moon.svg create mode 100644 frontend/src/icons/typescript.svg create mode 100644 frontend/src/icons/vite.svg create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css (limited to 'frontend/src') diff --git a/frontend/src/components/Events.js b/frontend/src/components/Events.js new file mode 100644 index 0000000..bca948a --- /dev/null +++ b/frontend/src/components/Events.js @@ -0,0 +1,106 @@ +import { Calendar } from './Icons.js'; + +const API_URL = '/api'; + +const loadEventsData = async () => { + try { + const response = await fetch(`${API_URL}/events`); + return response.json(); + } catch (e) { + console.error(e); + } +} + + +export const EventModal = (event) => { + const formId = `rsvp-form-${event.ID}`; + const modalId = `modal-event-${event.id}` + return ` +
+
+ +

RSVP to ${event.title}

+
+
+ + + +
+
+ + + + +
+
+
` +} + +export const EventCard = (e) => { + const eventDate = new Date(e.date); + const isPast = eventDate < new Date(); + return ` +
+
+ ${e.image_url && `${e.title} thumbnail`} +
+
+

${e.title}

+

${Calendar} ${eventDate.toLocaleDateString()}

+

Host: ${e.host?.name || `User ${e.host_id}`}

+ + ${e.description && `

${e.description}

`} +
+
+ + ${e.rsvps.length} + ${isPast ? 'went' : 'going'} + + ${!isPast ? ` + `: ''} +
+ ${EventModal(e)} +
+ ` +} + +export const EventsSection = (title, events) => { + return ` +
+

${title} events

+
+ ${events.map((e) => EventCard(e)).join('') || 'No events'} +
+
`; +} + +// IIFE to asynchronously load the Event data before exporting the component +// https://developer.mozilla.org/en-US/docs/Glossary/IIFE +export const Events = await (async () => { + const all = await loadEventsData(); + const past = all.filter((e) => (new Date(e.date) < new Date())); + const upcoming = all.filter((e) => (new Date(e.date) > new Date())); + return ` + ${EventsSection('Upcoming', upcoming)} + ${EventsSection('Past', past)} +`})() \ No newline at end of file diff --git a/frontend/src/components/Footer.js b/frontend/src/components/Footer.js new file mode 100644 index 0000000..86f36da --- /dev/null +++ b/frontend/src/components/Footer.js @@ -0,0 +1,10 @@ +import * as Icons from './Icons.js'; + +const Footer = ` + +`; + +export default Footer \ No newline at end of file diff --git a/frontend/src/components/Forms.js b/frontend/src/components/Forms.js new file mode 100644 index 0000000..9a0c087 --- /dev/null +++ b/frontend/src/components/Forms.js @@ -0,0 +1,67 @@ +const API_URL = import.meta.env.VITE_API_URL; + +export const setupForm = (form) => { + if (!form) return; + + const eventId = form.id.replace('rsvp-form-', ''); + const url = `${API_URL}/events/${eventId}/rsvp`; + const btn = document.getElementById(`submit-${form.id}`); + + async function sendData(form) { + const formData = new FormData(form); + const encoded = new URLSearchParams(); + for (let [key, value] of formData.entries) { + encoded.append(key, value); + } + try { + const response = await fetch(url, { + method: "POST", + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: encoded, + }); + if (response.status === 201) { + form.dataset.submitted = 'success'; + if (btn) { + btn.textContent = "You're going!"; + btn.setAttribute('disabled', ''); + } + } else if (response.status === 200) { + form.dataset.submitted = 'duplicate'; + if (btn) { + btn.textContent = "You're already going!"; + btn.setAttribute('disabled', ''); + } + } + } catch (e) { + form.dataset.submitted = 'error'; + console.error(e); + } + } + + form.addEventListener("submit", (event) => { + event.preventDefault(); + sendData(form); + }); + + if (btn) { + const inputs = form.querySelectorAll('input.rsvp-email'); + for (let input of inputs) { + input.addEventListener('input', () => { + if (!btn.textContent.startsWith('Submit')) { + btn.removeAttribute('disabled'); + btn.textContent = 'Submit RSVP'; + } + }); + } + } +}; + +export const setupForms = () => { + const rsvpForms = document.querySelectorAll('form'); + if (!rsvpForms) return; + for (let form of rsvpForms) { + setupForm(form); + } +}; \ No newline at end of file diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js new file mode 100644 index 0000000..cd50ba6 --- /dev/null +++ b/frontend/src/components/Header.js @@ -0,0 +1,33 @@ +import { Theme as ThemeIcon } from './Icons'; + +const themeToggleId = 'theme'; + +const Header = ` +
+
+

event me

+

All the events you never knew you needed to attend!

+
+ + ${ThemeIcon} + +
+`; + +export function setupThemeToggle() { + const toggleDarkMode = () => { + const doc = document.documentElement; + const currentTheme = doc.getAttribute('data-theme'); + if (currentTheme === 'dark') { + doc.setAttribute('data-theme', 'lite'); + } else if (currentTheme === 'light') { + doc.setAttribute('data-theme', 'dark'); + } + } + const themeToggle = document.getElementById(themeToggleId); + themeToggle.addEventListener('click', toggleDarkMode); + +} + + +export default Header diff --git a/frontend/src/components/Icons.js b/frontend/src/components/Icons.js new file mode 100644 index 0000000..c2eb993 --- /dev/null +++ b/frontend/src/components/Icons.js @@ -0,0 +1,19 @@ +import heart from '../icons/heart.svg?raw'; +import sunMoon from '../icons/sun-moon.svg?raw'; +import typescript from '../icons/typescript.svg?raw'; +import vite from '../icons/vite.svg?raw'; +import pico from '../icons/pico.svg?raw'; +import calendar from '../icons/calendar.svg?raw'; + +// Export the raw SVG strings +export const Heart = heart; +export const Theme = sunMoon; +export const TypeScript = typescript; +export const Vite = vite; +export const Pico = pico; +export const Calendar = calendar; + + + + + diff --git a/frontend/src/components/Main.js b/frontend/src/components/Main.js new file mode 100644 index 0000000..275578b --- /dev/null +++ b/frontend/src/components/Main.js @@ -0,0 +1,9 @@ +import { Events } from './Events'; + +const Main = ` +
+ ${Events} +
+`; + +export default Main; \ No newline at end of file diff --git a/frontend/src/components/Modal.js b/frontend/src/components/Modal.js new file mode 100644 index 0000000..d72385b --- /dev/null +++ b/frontend/src/components/Modal.js @@ -0,0 +1,80 @@ +/* + * Modal + * + * Pico.css - https://picocss.com + * Copyright 2019-2024 - Licensed under MIT + */ + +// Config +const isOpenClass = "modal-is-open"; +const openingClass = "modal-is-opening"; +const closingClass = "modal-is-closing"; +const scrollbarWidthCssVar = "--pico-scrollbar-width"; +const animationDuration = 400; // ms +let visibleModal = null; + +// Toggle modal +const toggleModal = (event) => { + event.preventDefault(); + const modal = document.getElementById(event.currentTarget.dataset.target); + if (!modal) return; + modal && (modal.open ? closeModal(modal) : openModal(modal)); +}; + +// Open modal +const openModal = (modal) => { + const { documentElement: html } = document; + const scrollbarWidth = getScrollbarWidth(); + if (scrollbarWidth) { + html.style.setProperty(scrollbarWidthCssVar, `${scrollbarWidth}px`); + } + html.classList.add(isOpenClass, openingClass); + setTimeout(() => { + visibleModal = modal; + html.classList.remove(openingClass); + }, animationDuration); + modal.showModal(); +}; + +// Close modal +const closeModal = (modal) => { + visibleModal = null; + const { documentElement: html } = document; + html.classList.add(closingClass); + setTimeout(() => { + html.classList.remove(closingClass, isOpenClass); + html.style.removeProperty(scrollbarWidthCssVar); + modal.close(); + }, animationDuration); +}; + +// Close with a click outside +document.addEventListener("click", (event) => { + if (visibleModal === null) return; + const modalContent = visibleModal.querySelector("article"); + const isClickInside = modalContent.contains(event.target); + !isClickInside && closeModal(visibleModal); +}); + +// Close with Esc key +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && visibleModal) { + closeModal(visibleModal); + } +}); + +// Get scrollbar width +const getScrollbarWidth = () => { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + return scrollbarWidth; +}; + + +// Initialize event listeners to open/close event pages +export const setupModals = () => { + // Add open/close button handlers + const togglers = document.querySelectorAll('.toggle-modal'); + for (let el of togglers) { + el.addEventListener("click", toggleModal); + } +}; \ No newline at end of file diff --git a/frontend/src/icons/calendar.svg b/frontend/src/icons/calendar.svg new file mode 100644 index 0000000..0360850 --- /dev/null +++ b/frontend/src/icons/calendar.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/frontend/src/icons/heart.svg b/frontend/src/icons/heart.svg new file mode 100644 index 0000000..ae83485 --- /dev/null +++ b/frontend/src/icons/heart.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/frontend/src/icons/pico.svg b/frontend/src/icons/pico.svg new file mode 100644 index 0000000..2494250 --- /dev/null +++ b/frontend/src/icons/pico.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/icons/sun-moon.svg b/frontend/src/icons/sun-moon.svg new file mode 100644 index 0000000..fb0a165 --- /dev/null +++ b/frontend/src/icons/sun-moon.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/frontend/src/icons/typescript.svg b/frontend/src/icons/typescript.svg new file mode 100644 index 0000000..d91c910 --- /dev/null +++ b/frontend/src/icons/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/icons/vite.svg b/frontend/src/icons/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/src/icons/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..cf91fa0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,26 @@ +import './style.css' +import Header, { setupThemeToggle } from './components/Header'; +import Main from './components/Main'; +import Footer from './components/Footer'; +import { setupModals } from './components/Modal'; +import { setupForms } from './components/Forms'; + + +// Quick and dirty - not for production! +const render = (html) => { + const app = document.querySelector('#app'); + app.innerHTML = html; + setupThemeToggle(); + setupModals(); + setupForms(); +} + + +render(` + ${Header} + ${Main} + ${Footer} +`); + + + diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..c7ca91e --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,81 @@ +/* Layout */ +body>header, +footer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +nav[aria-label="breadcrumb"] { + --pico-nav-breadcrumb-divider: '/'; +} + +h1, +h2, +h3, +h4 { + font-family: 'Parkinsans'; +} + +header h1, +header h2, +header h3, +header h4 { + color: var(--pico-primary); +} + +header svg { + color: var(--pico-contrast); +} + +body>header { + padding-bottom: 0px; +} + +svg { + color: inherit; +} + + +footer svg { + height: 1em; + width: auto; +} + +section.events [role="group"] { + display: grid; + max-width: 100%; + grid-template-columns: repeat(3, 1fr); + gap: .5em; +} + +article.event { + margin: 0px; + display: flex; + flex-direction: column; + justify-content: space-between; + border-radius: var(--pico-border-radius); +} + + +article.event main { + display: flex; + flex-direction: column; + justify-content: start; + height: 100%; +} + +article.event img { + object-fit: cover; + width: 100%; + display: block; + padding: 0px; + margin: 0px; + border-radius: inherit; +} + +article.event h4 { + color: var(--pico-primary); + min-height: 2em; +} \ No newline at end of file -- cgit v1.3