created frontend of a form to create events

This commit is contained in:
Madeleine 2026-06-21 16:51:07 -04:00
parent e6b7336440
commit 33c4e6f479
10 changed files with 950 additions and 0 deletions

24
frontend/README.md Normal file
View file

@ -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

41
frontend/package.json Normal file
View file

@ -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"
}
}

184
frontend/src/App.css Normal file
View file

@ -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);
}
}

62
frontend/src/App.jsx Normal file
View file

@ -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 (
<div>
<h1>Home</h1>
<p><Link to="/createevent">Create an event</Link></p>
</div>
);
}
export default function App() {
return (
<div>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/createevent" element={<EventForm />} />
</Routes>
</div>
);
}
// export default function App() {
// return (
// <div style={{ height: '100vh' }}>
// <MapContainer
// center={[40.437, -74.199]} // Keyport area
// zoom={13}
// style={{ height: '100%', width: '100%' }}
// >
// <TileLayer
// attribution="&copy; OpenStreetMap contributors"
// url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
// />
// <ClickHandler />
//
// <Marker position={[40.437, -74.199]}>
// <Popup>
// Hello from Leaflet!
// </Popup>
// </Marker>
// </MapContainer>
// </div>
// );
// }

View file

@ -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;
}

View file

@ -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 (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label>Name
<input {...register("name")} placeholder="Name"/>
{errors.name && <div style={{color: "red"}}>{errors.name.message}</div>}
</label>
</div>
<div>
<label>Description
<textarea {...register("description")} placeholder="Description"/>
{errors.description && <div style={{color: "red"}}>{errors.description.message}</div>}
</label>
</div>
<div>
<input {...register("url")} placeholder="URL" type="url"/>
{errors.url && <div style={{color: "red"}}>{errors.url.message}</div>}
</div>
{/* Map and search control */}
<div style={{marginTop: 8}}>
<div style={{height: 320, width: "100%"}}>
<MapContainer
center={markerPosition || defaultCenter}
zoom={markerPosition ? 13 : 11}
style={{height: "100%", width: "100%"}}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"/>
<GeoSearchControlComponent/>
<ClickToSetMarker/>
{markerPosition && <Marker position={markerPosition}/>}
</MapContainer>
</div>
<div style={{fontSize: 12, color: "#666", marginTop: 6}}>
Tip: search an address on the map bar or click the map to set location.
</div>
</div>
<div>
{/* Address input - if you want you can also update address automatically from search result */}
<input {...register("address")} placeholder="Address"/>
{errors.address && <div style={{color: "red"}}>{errors.address.message}</div>}
</div>
<div>
<label htmlFor="status">Status</label>
<select id="status" {...register("status")}>
{STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{errors.status && <div style={{color: "red"}}>{errors.status.message}</div>}
</div>
<div>
<input {...register("price")} placeholder="Price USD" type="number" step="1.0"/>
{errors.price && <div style={{color: "red"}}>{errors.price.message}</div>}
</div>
<div>
<label>
<input {...register("require_rsvp")} type="checkbox"/>
Require RSVP
</label>
</div>
<div>
<label>Start time
<input {...register("start_time")} type="datetime-local"/>
</label>
{errors.start_time && <div style={{color: "red"}}>{errors.start_time.message}</div>}
</div>
<div>
<label>End time
<input {...register("end_time")} type="datetime-local"/>
</label>
{errors.end_time && <div style={{color: "red"}}>{errors.end_time.message}</div>}
</div>
<div>
<label>Rain date
<input {...register("rain_date")} type="datetime-local"/>
</label>
{errors.rain_date && <div style={{color: "red"}}>{errors.rain_date.message}</div>}
</div>
<div>
<input {...register("email")} placeholder="Email" type="email"/>
{errors.email && <div style={{color: "red"}}>{errors.email.message}</div>}
</div>
<div>
<input {...register("phone_number")} placeholder="Phone number" type="tel"/>
{errors.phone_number && <div style={{color: "red"}}>{errors.phone_number.message}</div>}
</div>
{/* Hidden fields for coordinates (these will be submitted) */}
<input type="hidden" {...register("latitude")} />
<input type="hidden" {...register("longitude")} />
<div style={{marginTop: 12}}>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Event"}
</button>
</div>
</form>
);
}

111
frontend/src/index.css Normal file
View file

@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

16
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import React from "react";
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import 'leaflet/dist/leaflet.css';
import { BrowserRouter } from "react-router-dom";
const root = createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View file

@ -0,0 +1,85 @@
/*
Schema for outgoing POST body to /api/event
- Accepts form values (strings/numbers) and coerces where useful.
- Transforms flat latitude/longitude fields into a nested `location` object:
location: { latitude: number | null, longitude: number | null }
- Removes the flat latitude/longitude fields from the final payload.
*/
import {z} from "zod";
export const EventPostSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().min(5, "Description has to be more than 5 letters."),
url: z.string().url().nullable().optional(),
address: z.string().min(5, "Address is required"),
status: z.enum(["scheduled", "completed", "canceled"]).optional(),
// price: allow number or string or null; coerce to number or null
price: z
.union([z.string(), z.number(), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null || val === "") return null;
const n = typeof val === "string" ? parseFloat(val) : val;
return Number.isFinite(n) ? n : null;
}),
require_rsvp: z.boolean().optional().default(false),
// start_time: optional, but if provided must be a valid Date and in the future.
start_time: z.preprocess((val) => {
if (val === "" || val === undefined || val === null) return null;
if (typeof val === "string") {
const d = new Date(val);
return isNaN(d.getTime()) ? val : d;
}
return val;
},
z.union([
z.date({invalid_type_error: "Start date must be a valid datetime"}),
z.null()
])
.refine((v) => v === null || v.getTime() > Date.now(), {
message: "Start date must be in the future",
})
)
.transform((val) => (val === null ? null : val.toISOString())),
end_time: z.union([z.string(), z.date(), z.null()])
.optional()
.transform((val) => {
if (!val) return null;
return val instanceof Date ? val.toISOString() : val;
}),
rain_date: z
.union([z.string(), z.date(), z.null()])
.optional()
.transform((val) => {
if (!val) return null;
return val instanceof Date ? val.toISOString() : val;
}),
email: z.string().email().nullable().optional(),
phone_number: z.string().nullable().optional(),
// Flat latitude and longitude fields for form handling
latitude: z.union([z.string(), z.number(), z.null()]).optional(),
longitude: z.union([z.string(), z.number(), z.null()]).optional(),
})
.transform((data) => {
// Transform flat latitude/longitude into nested location object
if (data.latitude || data.longitude) {
data.location = {
lat: data.latitude ? parseFloat(data.latitude) : null,
lng: data.longitude ? parseFloat(data.longitude) : null,
};
}
// Remove flat fields from payload
delete data.latitude;
delete data.longitude;
return data;
});

View file

@ -0,0 +1,46 @@
import {EventPostSchema} from "../schemas/eventPostSchema";
// Service responsible for sending event POST requests to the API.
// - Validates and coerces the outgoing payload using EventPostSchema
// - Logs validation errors (if any) and the final payload sent
// - Sends the request with CSRF token (if available) and returns parsed JSON response
const API_BASE = import.meta.env.VITE_API_BASE
function getCSRFToken() {
const name = "csrftoken";
const match = document.cookie.match(new RegExp('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'));
return match ? match.pop() : null;
}
export default class EventService {
constructor(base = API_BASE) {
this.base = base;
}
async createEvent(rawPayload) {
// Validate and coerce the outgoing payload
const parsed = EventPostSchema.parse(rawPayload); // will throw if invalid
console.log("EventService sending payload:", JSON.stringify(rawPayload, null, 2)); // Log here
const res = await fetch(`${this.base}/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// X-CSRFToken if using django session auth in browser
...(getCSRFToken() ? {"X-CSRFToken": getCSRFToken()} : {}),
},
credentials: "same-origin", // change to 'include' if cross-site cookies
body: JSON.stringify(parsed),
});
if (!res.ok) {
const text = await res.text();
const err = new Error("Failed to create event");
err.status = res.status;
err.body = text;
throw err;
}
return res.json();
}
}