summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorAnjana Vakil <contact@anjana.dev>2025-08-26 12:40:16 -0500
committerAnjana Vakil <contact@anjana.dev>2025-08-26 12:40:16 -0500
commit1dc4f56425209d4ce1d7bb78ec8b5e7b5a755a82 (patch)
tree58d06cd695ae17302daff7a87d9096f1d39ea54a /frontend/src
reset
Diffstat (limited to 'frontend/src')
-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
-rw-r--r--frontend/src/icons/calendar.svg4
-rw-r--r--frontend/src/icons/heart.svg4
-rw-r--r--frontend/src/icons/pico.svg21
-rw-r--r--frontend/src/icons/sun-moon.svg4
-rw-r--r--frontend/src/icons/typescript.svg1
-rw-r--r--frontend/src/icons/vite.svg1
-rw-r--r--frontend/src/main.js26
-rw-r--r--frontend/src/style.css81
15 files changed, 466 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
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path fill="currentColor"
+ d="M19 19H5V8h14m-3-7v2H8V1H6v2H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-1V1m-1 11h-5v5h5z" />
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path fill="currentColor"
+ d="m12 21.35l-1.45-1.32C5.4 15.36 2 12.27 2 8.5C2 5.41 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08C13.09 3.81 14.76 3 16.5 3C19.58 3 22 5.41 22 8.5c0 3.77-3.4 6.86-8.55 11.53z" />
+</svg> \ 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 @@
+<svg fill="none" height="458" viewBox="0 0 1064 458" width="1064" xmlns="http://www.w3.org/2000/svg">
+ <path
+ d="m993.708 161.083c-1.475.567-2.641 1.733-3.208 3.208l-21.822 56.738c-1.836 4.774-8.59 4.774-10.426 0l-21.823-56.738c-.567-1.475-1.733-2.641-3.208-3.208l-56.738-21.823c-4.774-1.836-4.774-8.59 0-10.426l56.738-21.822c1.475-.567 2.641-1.733 3.208-3.208l21.823-56.7383c1.836-4.7737 8.59-4.7737 10.426 0l21.822 56.7383c.567 1.475 1.733 2.641 3.208 3.208l56.742 21.822c4.77 1.836 4.77 8.59 0 10.426z"
+ fill="#ffbf00" />
+ <path
+ d="m834.63 86.9817c-1.836 4.7738-8.59 4.7738-10.426 0l-7.859-20.4337c-.567-1.475-1.733-2.6407-3.208-3.208l-20.433-7.8592c-4.774-1.836-4.774-8.59 0-10.426l20.433-7.8591c1.475-.5674 2.641-1.733 3.208-3.2081l7.859-20.4337c1.836-4.77377 8.59-4.77375 10.426 0l7.86 20.4337c.567 1.4751 1.733 2.6407 3.208 3.2081l20.433 7.8591c4.774 1.836 4.774 8.59 0 10.426l-20.433 7.8592c-1.475.5673-2.641 1.733-3.208 3.208z"
+ fill="#ff9500" />
+ <path
+ d="m879.209 230.899c-1.475.568-2.64 1.733-3.208 3.208l-7.859 20.434c-1.836 4.774-8.59 4.774-10.426 0l-7.859-20.434c-.567-1.475-1.733-2.64-3.208-3.208l-20.434-7.859c-4.773-1.836-4.773-8.59 0-10.426l20.434-7.859c1.475-.567 2.641-1.733 3.208-3.208l7.859-20.434c1.836-4.774 8.59-4.774 10.426 0l7.859 20.434c.568 1.475 1.733 2.641 3.208 3.208l20.434 7.859c4.774 1.836 4.774 8.59 0 10.426z"
+ fill="#ff9500" />
+ <g fill="currentcolor">
+ <path
+ d="m0 457.995v-282.83h59.1396l3.6208 37.172v245.658zm119.889-75.96c-16.629 0-30.5761-4.175-41.8408-12.525-11.2647-8.62-19.7132-20.876-25.3455-36.768-5.6324-15.892-8.4485-34.748-8.4485-56.566 0-22.088 2.8161-40.943 8.4485-56.566 5.6323-15.893 14.0808-28.014 25.3455-36.364 11.2647-8.62 25.2118-12.93 41.8408-12.93 18.774 0 35.001 4.31 48.679 12.93 13.679 8.35 24.139 20.471 31.38 36.364 7.242 15.623 10.863 34.478 10.863 56.566 0 21.818-3.621 40.674-10.863 56.566-7.241 15.892-17.701 28.148-31.38 36.768-13.678 8.35-29.905 12.525-48.679 12.525zm-16.093-58.182c8.046 0 15.154-2.02 21.323-6.061 6.168-4.04 11.13-9.562 14.885-16.566 3.755-7.272 5.632-15.623 5.632-25.05 0-9.428-1.743-17.643-5.23-24.647-3.486-7.273-8.448-12.929-14.885-16.97-6.169-4.04-13.276-6.06-21.323-6.06-8.0458 0-15.2874 2.02-21.7244 6.06-6.1687 4.041-10.9964 9.697-14.4831 16.97s-5.2301 15.488-5.2301 24.647c0 9.427 1.7434 17.778 5.2301 25.05 3.4867 7.004 8.3144 12.526 14.4831 16.566 6.1688 4.041 13.2763 6.061 21.3224 6.061z" />
+ <path
+ d="m245.196 377.187v-202.022h62.76v202.022zm31.38-227.881c-9.387 0-17.702-3.502-24.943-10.505-6.974-7.273-10.46-15.623-10.46-25.051 0-9.966 3.486-18.3165 10.46-25.0505 7.241-7.0035 15.556-10.5052 24.943-10.5052 9.655 0 17.97 3.5017 24.943 10.5052 6.973 6.734 10.46 15.0845 10.46 25.0505 0 9.428-3.487 17.778-10.46 25.051-6.973 7.003-15.288 10.505-24.943 10.505z" />
+ <path
+ d="m452.936 382.035c-21.457 0-40.634-4.444-57.531-13.333-16.629-9.159-29.637-21.684-39.024-37.576-9.387-16.162-14.081-34.479-14.081-54.95 0-20.741 4.694-39.058 14.081-54.95 9.387-15.893 22.261-28.283 38.622-37.172 16.629-9.159 35.671-13.738 57.128-13.738 20.652 0 39.56 5.253 56.726 15.758 17.165 10.505 29.502 25.724 37.012 45.657l-59.542 20.202c-2.95-6.465-7.912-11.717-14.885-15.758-6.705-4.309-14.215-6.464-22.53-6.464-8.314 0-15.69 2.02-22.127 6.06-6.168 3.771-11.13 9.159-14.885 16.162-3.487 7.004-5.23 15.084-5.23 24.243 0 9.158 1.743 17.239 5.23 24.242 3.755 6.734 8.851 12.122 15.288 16.162 6.705 4.041 14.215 6.061 22.529 6.061 8.315 0 15.824-2.155 22.529-6.465 6.706-4.31 11.667-9.966 14.886-16.97l59.542 20.202c-7.778 20.203-20.25 35.691-37.415 46.465-16.897 10.775-35.672 16.162-56.323 16.162z" />
+ <path
+ d="m672.537 382.035c-21.188 0-39.962-4.444-56.323-13.333-16.093-9.159-28.832-21.684-38.22-37.576-9.119-15.893-13.678-34.209-13.678-54.95s4.559-39.058 13.678-54.95c9.119-15.893 21.725-28.283 37.818-37.172 16.36-9.159 34.866-13.738 55.518-13.738 21.189 0 39.829 4.579 55.922 13.738 16.36 8.889 29.1 21.279 38.219 37.172 9.119 15.892 13.679 34.209 13.679 54.95s-4.56 39.057-13.679 54.95c-9.119 15.892-21.725 28.417-37.817 37.576-15.824 8.889-34.196 13.333-55.117 13.333zm-.402-59.798c8.314 0 15.422-1.886 21.322-5.657 6.169-4.04 10.997-9.428 14.484-16.162 3.486-7.003 5.23-15.084 5.23-24.242 0-9.159-1.878-17.105-5.633-23.839-3.486-7.003-8.314-12.39-14.483-16.162-6.169-4.04-13.276-6.06-21.322-6.06s-15.154 2.02-21.323 6.06c-6.168 3.772-10.996 9.159-14.483 16.162-3.487 6.734-5.23 14.68-5.23 23.839 0 8.889 1.743 16.835 5.23 23.838 3.487 7.004 8.315 12.526 14.483 16.566 6.437 3.771 13.679 5.657 21.725 5.657z" />
+ </g>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path fill="currentcolor"
+ d="M7.5 2c-1.79 1.15-3 3.18-3 5.5s1.21 4.35 3.03 5.5C4.46 13 2 10.54 2 7.5A5.5 5.5 0 0 1 7.5 2m11.57 1.5l1.43 1.43L4.93 20.5L3.5 19.07zm-6.18 2.43L11.41 5L9.97 6l.42-1.7L9 3.24l1.75-.12l.58-1.65L12 3.1l1.73.03l-1.35 1.13zm-3.3 3.61l-1.16-.73l-1.12.78l.34-1.32l-1.09-.83l1.36-.09l.45-1.29l.51 1.27l1.36.03l-1.05.87zM19 13.5a5.5 5.5 0 0 1-5.5 5.5c-1.22 0-2.35-.4-3.26-1.07l7.69-7.69c.67.91 1.07 2.04 1.07 3.26m-4.4 6.58l2.77-1.15l-.24 3.35zm4.33-2.7l1.15-2.77l2.2 2.54zm1.15-4.96l-1.14-2.78l3.34.24zM9.63 18.93l2.77 1.15l-2.53 2.19z" />
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> \ 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