From 33c4e6f47922daf5b076e8a8bbe02ebbe7ffcc8a Mon Sep 17 00:00:00 2001 From: Madeleine Date: Sun, 21 Jun 2026 16:51:07 -0400 Subject: [PATCH] created frontend of a form to create events --- frontend/README.md | 24 +++ frontend/package.json | 41 ++++ frontend/src/App.css | 184 +++++++++++++++++ frontend/src/App.jsx | 62 ++++++ frontend/src/components/eventForm.css | 124 ++++++++++++ frontend/src/components/eventForm.jsx | 257 ++++++++++++++++++++++++ frontend/src/index.css | 111 ++++++++++ frontend/src/main.jsx | 16 ++ frontend/src/schemas/eventPostSchema.js | 85 ++++++++ frontend/src/services/eventService.js | 46 +++++ 10 files changed, 950 insertions(+) create mode 100644 frontend/README.md create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/eventForm.css create mode 100644 frontend/src/components/eventForm.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/schemas/eventPostSchema.js create mode 100644 frontend/src/services/eventService.js diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..b2dc66f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,24 @@ +# Localist Frontend (React + Vite) + +This directory contains the frontend application for Localist built with React + Vite. + +It provides an event creation form with an embedded Leaflet map and a search bar (leaflet-geosearch). The form uses react-hook-form with a Zod schema to validate and transform outgoing payloads (the schema nests flat latitude/longitude into a `location` object before sending). + +Quick links +- Source: `src/` + - Main component: `src/components/eventForm.jsx` + - Validation/transform schema: `src/schemas/eventPostSchema.js` + - API client: `src/services/eventService.js` +- Vite config: `vite.config.js` + +Prerequisites +- Node.js (16+ recommended) +- npm or yarn +- Django backend running locally at http://127.0.0.1:8000 if you want the API to respond (the Vite config proxies `/api` to that address by default) + +Install dependencies +```bash +cd localist/frontend +npm install +# or +yarn \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d7f5f84 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@hookform/resolvers": "^5.4.0", + "@mui/material": "^9.1.1", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", + "leaflet-geosearch": "^4.4.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.80.0", + "react-leaflet": "^5.0.0", + "react-router-dom": "^7.18.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "postcss": "^8.5.15", + "tailwindcss": "^4.3.1", + "vite": "^8.0.12" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..eb4c07b --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,62 @@ +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import { useMapEvents } from 'react-leaflet'; +import { Routes, Route, Link } from "react-router-dom"; +import EventForm from './components/eventForm.jsx'; // <- add this import + +function ClickHandler() { + useMapEvents({ + click(e) { + console.log( + e.latlng.lat, + e.latlng.lng + ); + }, + }); + + return null; +} + +// src/App.jsx +function Home() { + return ( +
+

Home

+

Create an event

+
+ ); +} + +export default function App() { + return ( +
+ + } /> + } /> + +
+ ); +} + +// export default function App() { +// return ( +//
+// +// +// +// +// +// +// Hello from Leaflet! +// +// +// +//
+// ); +// } \ No newline at end of file diff --git a/frontend/src/components/eventForm.css b/frontend/src/components/eventForm.css new file mode 100644 index 0000000..0175a81 --- /dev/null +++ b/frontend/src/components/eventForm.css @@ -0,0 +1,124 @@ +/* eventForm.css */ +.event-form { + max-width: 900px; + margin: 28px auto; + padding: 20px; + background: #fff; + border-radius: 10px; + box-shadow: 0 6px 20px rgba(0,0,0,0.06); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; + color: #111827; +} + +.form-title { + margin: 0 0 16px; + font-size: 1.5rem; + font-weight: 600; + color: #111827; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +/* On wider screens use two columns */ +@media (min-width: 700px) { + .form-grid { + grid-template-columns: 1fr 1fr; + gap: 14px; + } +} + +/* Make these fields span both columns */ +.full-width { + grid-column: 1 / -1; +} + +.form-field { + display: flex; + flex-direction: column; +} + +.form-field label { + font-size: 0.9rem; + margin-bottom: 6px; + color: #374151; +} + +.form-field input[type="text"], +.form-field input[type="url"], +.form-field input[type="email"], +.form-field input[type="tel"], +.form-field input[type="number"], +.form-field input[type="datetime-local"], +.form-field textarea, +.form-field select { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px 10px; + font-size: 0.95rem; + outline: none; + transition: box-shadow .12s ease, border-color .12s ease; +} + +.form-field textarea { + min-height: 110px; + resize: vertical; +} + +.form-field input:focus, +.form-field textarea:focus, +.form-field select:focus { + border-color: #60a5fa; + box-shadow: 0 4px 10px rgba(96,165,250,0.12); +} + +/* Checkbox row styling */ +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; +} + +/* Actions */ +.form-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.btn { + background: linear-gradient(180deg, #2563eb, #1e40af); + color: #fff; + border: none; + padding: 10px 16px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + box-shadow: 0 6px 18px rgba(30,64,175,0.12); +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(30,64,175,0.14); +} + +.btn:active { + transform: translateY(0); + box-shadow: 0 6px 18px rgba(30,64,175,0.12); +} + +/* Small screens tweak */ +@media (max-width: 420px) { + .event-form { + padding: 14px; + } +} + +.leaflet-control-attribution .leaflet-attrib-flag { + display: none; +} \ No newline at end of file diff --git a/frontend/src/components/eventForm.jsx b/frontend/src/components/eventForm.jsx new file mode 100644 index 0000000..d779894 --- /dev/null +++ b/frontend/src/components/eventForm.jsx @@ -0,0 +1,257 @@ +/* +EventForm - create/edit event UI with Leaflet map + leaflet-geosearch. +- Uses react-hook-form + Zod resolver for validation/coercion +- Map provides a search bar (OpenStreetMapProvider) and click-to-place marker +- When a place is chosen, address + coordinates are written into the form values +- Coordinates are submitted as flat fields; the schema transforms them into `location` +*/ +import React, {useEffect, useState} from "react"; +import {useForm} from "react-hook-form"; +import {zodResolver} from "@hookform/resolvers/zod"; +import EventService from "../services/eventService"; +import {EventPostSchema} from "../schemas/eventPostSchema"; + +// Leaflet icon compatibility helper (handles bundler issues) +import 'leaflet-defaulticon-compatibility'; +import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css'; + +// Leaflet / React-Leaflet +import {MapContainer, TileLayer, Marker, useMap, useMapEvents} from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import "./eventForm.css" + +// GeoSearch +import {GeoSearchControl, OpenStreetMapProvider} from "leaflet-geosearch"; +import "leaflet-geosearch/dist/geosearch.css"; + +/* Fix leaflet icon paths for many bundlers (Vite/CRA/Webpack) */ +import markerUrl from "leaflet/dist/images/marker-icon.png"; +import markerRetinaUrl from "leaflet/dist/images/marker-icon-2x.png"; +import markerShadowUrl from "leaflet/dist/images/marker-shadow.png"; + +L.Icon.Default.mergeOptions({ + iconRetinaUrl: markerRetinaUrl, + iconUrl: markerUrl, + shadowUrl: markerShadowUrl, +}); + +export default function EventForm() { + const eventService = new EventService(); + const [serverError, setServerError] = useState(null); + + const { + register, + handleSubmit, + formState: {errors, isSubmitting, isDirty}, + reset, + setValue, + watch, + } = useForm({ + resolver: zodResolver(EventPostSchema), + defaultValues: { + name: "Madeleine's birthday", + description: "test", + url: "http://localhost:8080", + address: "31 Fleetwood", + status: "scheduled", + price: "10", + require_rsvp: false, + start_time: "", + end_time: "", + rain_date: "", + email: "madeleienma07@gmail.com", + phone_number: "8482189789", + latitude: "", // hidden field for latitude + longitude: "", // hidden field for longitude + }, + }); + // Status options for the select dropdown + const STATUS_OPTIONS = [ + {value: "scheduled", label: "Scheduled"}, + {value: "completed", label: "Completed"}, + {value: "canceled", label: "Canceled"}, + ]; + + // submit + const onSubmit = async (data) => { + setServerError(null); + try { + // Schema handles transformation - data is already nested as { location: { latitude, longitude } } + await eventService.createEvent(data); + alert("Your event has been created successfully"); + reset(); + } catch (err) { + console.error(err); + setServerError(err.body ?? err.message ?? "Network error"); + alert("Failed to save: " + (err.status || "") + " " + JSON.stringify(err.body || err.message)); + } + }; + // Reading lat/lng so the marker can follow them + const latitude = watch("latitude"); + const longitude = watch("longitude"); + + // Map child that adds the GeoSearch control and listens for results + function GeoSearchControlComponent() { + const map = useMap(); + useEffect(() => { + const provider = new OpenStreetMapProvider(); + + const searchControl = new GeoSearchControl({ + provider, + style: "bar", + showMarker: false, + retainZoomLevel: false, + autoClose: true, + keepResult: false, + }); + + map.addControl(searchControl); + + const onShowLocation = (e) => { + const {x: lon, y: lat, label} = e.location; + // Set all three fields from the search result + setValue("address", label, {shouldDirty: true, shouldValidate: true}); + setValue("latitude", lat, {shouldDirty: true, shouldValidate: true}); + setValue("longitude", lon, {shouldDirty: true, shouldValidate: true}); + map.setView([lat, lon], Math.max(map.getZoom(), 13)); + }; + + map.on("geosearch/showlocation", onShowLocation); + + return () => { + map.removeControl(searchControl); + map.off("geosearch/showlocation", onShowLocation); + }; + }, [map, setValue]); + + return null; + } + + // Map child that listens for click events to set coordinates + function ClickToSetMarker() { + useMapEvents({ + click(e) { + setValue("latitude", e.latlng.lat, {shouldDirty: true, shouldValidate: true}); + setValue("longitude", e.latlng.lng, {shouldDirty: true, shouldValidate: true}); + }, + }); + return null; + } + + // Default center if no coordinates set + const defaultCenter = [40.35, -74.31]; + + // parse lat/lng from watched values + const markerPosition = (latitude && longitude) ? [parseFloat(latitude), parseFloat(longitude)] : null; + + return ( +
+
+ +
+ +
+