summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/Events.js106
-rw-r--r--frontend/src/components/Footer.js10
-rw-r--r--frontend/src/components/Forms.js67
-rw-r--r--frontend/src/components/Header.js33
-rw-r--r--frontend/src/components/Icons.js19
-rw-r--r--frontend/src/components/Main.js9
-rw-r--r--frontend/src/components/Modal.js80
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