buidlingreactUI #27
24
frontend/README.md
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
|
dominic
commented
is it react standard for the file to be uppercase App.css? having an uppercase letter in file is weird and i would only do it if it was required by react is it react standard for the file to be uppercase App.css? having an uppercase letter in file is weird and i would only do it if it was required by react
|
||||
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 {
|
||||
|
dominic
commented
what actually is vite, why is it required what actually is vite, why is it required
|
||||
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 {
|
||||
|
dominic
commented
are these better as ids or classes? are these better as ids or classes?
|
||||
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 {
|
||||
|
dominic
commented
i feel like a lot of these names are ambiguous. like next steps what, docs what? I imagine we will have a documentation page. i feel like a lot of these names are ambiguous. like next steps what, docs what? I imagine we will have a documentation page.
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
|
dominic
commented
this implies we will have multiple link styles... i would take a look at a lot of your nested css and see if there are things that could be put at the global level, why do we need a special link here, it is just going to lead to an inconsistent UI this implies we will have multiple link styles... i would take a look at a lot of your nested css and see if there are things that could be put at the global level, why do we need a special link here, it is just going to lead to an inconsistent UI
|
||||
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
|
|
@ -0,0 +1,62 @@
|
|||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
|
dominic
commented
all imports should at least be in some sort of alphabetical order. idk how best to do that in react tho all imports should at least be in some sort of alphabetical order. idk how best to do that in react tho
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
import { Routes, Route, Link } from "react-router-dom";
|
||||
import EventForm from './components/eventForm.jsx'; // <- add this import
|
||||
|
||||
|
dominic
commented
what is a .jsx file anyway? what is a .jsx file anyway?
|
||||
function ClickHandler() {
|
||||
|
dominic
commented
this function is just for debugging, probably should not be in master this function is just for debugging, probably should not be in master
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
console.log(
|
||||
e.latlng.lat,
|
||||
e.latlng.lng
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// src/App.jsx
|
||||
function Home() {
|
||||
|
madeleinema marked this conversation as resolved
dominic
commented
the lower function has export default function but this one just has function, why? what do these functions even do? the lower function has export default function but this one just has function, why?
what do these functions even do?
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
<p><Link to="/createevent">Create an event</Link></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div>
|
||||
<Routes>
|
||||
|
madeleinema marked this conversation as resolved
dominic
commented
this has routes, the other doesn't, why? this has routes, the other doesn't, why?
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/createevent" element={<EventForm />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// export default function App() {
|
||||
|
madeleinema marked this conversation as resolved
dominic
commented
delete commented code delete commented code
|
||||
// return (
|
||||
// <div style={{ height: '100vh' }}>
|
||||
// <MapContainer
|
||||
// center={[40.437, -74.199]} // Keyport area
|
||||
// zoom={13}
|
||||
// style={{ height: '100%', width: '100%' }}
|
||||
// >
|
||||
// <TileLayer
|
||||
// attribution="© 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>
|
||||
// );
|
||||
// }
|
||||
124
frontend/src/components/eventForm.css
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* eventForm.css */
|
||||
|
dominic
commented
damn okay. we have a lot of css in multiple files. you need to probably really create a single css file and inherit it in most cases, with only minor tweaks for each page. damn okay. we have a lot of css in multiple files. you need to probably really create a single css file and inherit it in most cases, with only minor tweaks for each page.
|
||||
.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;
|
||||
}
|
||||
257
frontend/src/components/eventForm.jsx
Normal 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";
|
||||
|
dominic
commented
organize imports better organize imports better
|
||||
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({
|
||||
|
madeleinema marked this conversation as resolved
dominic
commented
what does this do? what does this do?
|
||||
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: {
|
||||
|
dominic
commented
this is good for dev, but we need to remove them for prod. this is good for dev, but we need to remove them for prod.
|
||||
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
|
||||
|
dominic
commented
remove ai slop comments remove ai slop comments
|
||||
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");
|
||||
|
dominic
commented
i dont love alerts for users. better to do message toast like django i dont love alerts for users. better to do message toast like django
|
||||
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 (
|
||||
|
dominic
commented
why is this not in app.jsx then? im not saying to do that, but this is inconsistent. why is this not in app.jsx then? im not saying to do that, but this is inconsistent.
|
||||
<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}}>
|
||||
|
dominic
commented
you can probably abstract the map html into its own component, because it is going to have to be reused again you can probably abstract the map html into its own component, because it is going to have to be reused again
|
||||
<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 */}
|
||||
|
madeleinema marked this conversation as resolved
dominic
commented
ai slop comment ai slop comment
|
||||
<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
|
|
@ -0,0 +1,111 @@
|
|||
:root {
|
||||
|
dominic
commented
damn, more css damn, more css
|
||||
--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) {
|
||||
|
dominic
commented
probably wana do prefers-color-scheme: system (idr if that is what it is tho) probably wana do prefers-color-scheme: system (idr if that is what it is tho)
|
||||
: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
|
|
@ -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>
|
||||
);
|
||||
85
frontend/src/schemas/eventPostSchema.js
Normal 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."),
|
||||
|
madeleinema marked this conversation as resolved
dominic
commented
what is the first integer in min function mean. what is the first integer in min function mean.
|
||||
url: z.string().url().nullable().optional(),
|
||||
address: z.string().min(5, "Address is required"),
|
||||
|
||||
status: z.enum(["scheduled", "completed", "canceled"]).optional(),
|
||||
|
dominic
commented
seems like you have statuses written in two places, would be better if they were shared somehow seems like you have statuses written in two places, would be better if they were shared somehow
|
||||
|
||||
// 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;
|
||||
});
|
||||
46
frontend/src/services/eventService.js
Normal 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
|
||||
|
dominic
commented
ai slop comments and unnecessary console.log ai slop comments and unnecessary console.log
|
||||
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();
|
||||
}
|
||||
}
|
||||
why are dev dependencies different from prod dependencies?