diff options
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/Events.js | 106 | ||||
| -rw-r--r-- | frontend/src/components/Footer.js | 10 | ||||
| -rw-r--r-- | frontend/src/components/Forms.js | 67 | ||||
| -rw-r--r-- | frontend/src/components/Header.js | 33 | ||||
| -rw-r--r-- | frontend/src/components/Icons.js | 19 | ||||
| -rw-r--r-- | frontend/src/components/Main.js | 9 | ||||
| -rw-r--r-- | frontend/src/components/Modal.js | 80 |
7 files changed, 324 insertions, 0 deletions
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 `<dialog id="${modalId}"> + <article> + <header> + <button + aria-label="Close" + rel="prev" + data-target="${modalId}" + class="toggle-modal" + ></button> + <h3>RSVP to ${event.title}</h3> + </header> + <form id="${formId}" data-modal="${modalId}" + action="${API_URL}/events/${event.id}/rsvp" + method="POST" + > + <label for="rsvp-name">Name: + <input type="text" class="rsvp-name" name="name" required /> + </label> + <label for="rsvp-email">Email: + <input type="email" class="rsvp-email" name="email" required /> + </label> + + </form> + <footer> + <button + role="button" + class="toggle-modal cancel" + data-target="${modalId}" + >Cancel</button> + + <button id="submit-${formId}" role="button" form="${formId}" type="submit">Submit RSVP</button> + + </footer> + </article> + </dialog>` +} + +export const EventCard = (e) => { + const eventDate = new Date(e.date); + const isPast = eventDate < new Date(); + return ` +<article class="event" > +<header> + ${e.image_url && `<img src=${e.image_url} alt="${e.title} thumbnail" />`} +</header> + <main> + <h4>${e.title}</h4> + <p>${Calendar} ${eventDate.toLocaleDateString()}</p> + <p>Host: ${e.host?.name || `User ${e.host_id}`}</p> + + ${e.description && `<p>${e.description}</p>`} + </main> + <footer> + <span> + ${e.rsvps.length} + ${isPast ? 'went' : 'going'} + </span> + ${!isPast ? ` + <button role="button" data-target="modal-event-${e.id}" class="toggle-modal" + title="RSVP to ${e.title}" + > + RSVP + </button>`: ''} + </footer> + ${EventModal(e)} +</article> + ` +} + +export const EventsSection = (title, events) => { + return ` + <section class='events'> + <h2>${title} events </h2> + <div role = "group"> + ${events.map((e) => EventCard(e)).join('') || 'No events'} + </div> + </section>`; +} + +// 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 = ` +<footer> +<p>Built with ${Icons.TypeScript} + ${Icons.Vite} + ${Icons.Pico} + ${Icons.Heart} at FrontendMasters</p> +<p>© 2024</p> +</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 = ` +<header> + <hgroup> + <h1 class=".parkinsans">event me</h1> + <p>All the events you never knew you needed to attend!</p> + </hgroup> + <a href="#" role="toggle" id="${themeToggleId}" title="Toggle color scheme" > + ${ThemeIcon} + </a> +</header> +`; + +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 = ` +<main class="container"> + ${Events} + </main> +`; + +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 |
