diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a07ce72..67ff84d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router' import Banner from './components/Banner' import SearchPage from './pages/SearchPage' import SightingDetailPage from './pages/SightingDetailPage' +import SubmitSightingPage from './pages/SubmitSightingPage' import './App.css' function App() { @@ -12,6 +13,7 @@ function App() { } /> } /> + } /> diff --git a/frontend/src/components/Banner.css b/frontend/src/components/Banner.css index 810fd39..bd3889d 100644 --- a/frontend/src/components/Banner.css +++ b/frontend/src/components/Banner.css @@ -4,11 +4,28 @@ margin-top: 16px; } +.banner__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + max-width: 1062px; + margin: 0 auto; +} + .banner__title { + color: var(--text-h); + font-size: 1.5rem; + text-decoration: none; margin: 0; letter-spacing: -0.04em; } +.banner__submit { + color: var(--accent); + text-underline-offset: 4px; +} + .banner__ufo { color: var(--accent); font-weight: 800; diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx index 5052266..8c4f53f 100644 --- a/frontend/src/components/Banner.tsx +++ b/frontend/src/components/Banner.tsx @@ -1,11 +1,15 @@ import './Banner.css' +import { Link } from 'react-router' function Banner() { return (
-

- UFOund it -

+
+ + UFOund it + + Report a sighting +
) } diff --git a/frontend/src/components/LocationPicker.css b/frontend/src/components/LocationPicker.css new file mode 100644 index 0000000..a9752b0 --- /dev/null +++ b/frontend/src/components/LocationPicker.css @@ -0,0 +1,17 @@ +.location-picker { + height: 360px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 4px; +} + +.location-picker .leaflet-container { + height: 100%; + width: 100%; +} + +@media (max-width: 640px) { + .location-picker { + height: 280px; + } +} \ No newline at end of file diff --git a/frontend/src/components/LocationPicker.tsx b/frontend/src/components/LocationPicker.tsx new file mode 100644 index 0000000..01496e2 --- /dev/null +++ b/frontend/src/components/LocationPicker.tsx @@ -0,0 +1,52 @@ +import L from 'leaflet' +import { MapContainer, Marker, TileLayer, useMapEvents } from 'react-leaflet' +import 'leaflet/dist/leaflet.css' +import './LocationPicker.css' + +import markerIcon from 'leaflet/dist/images/marker-icon.png' +import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png' +import markerShadow from 'leaflet/dist/images/marker-shadow.png' + +const marker = L.icon({ + iconUrl: markerIcon, + iconRetinaUrl: markerIconRetina, + shadowUrl: markerShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + shadowSize: [41, 41], +}) + +interface LocationPickerProps { + position: [number, number] | null + onPositionChange: (position: [number, number]) => void +} + +function MapClickHandler({ onPositionChange }: Pick) { + useMapEvents({ + click: (event) => onPositionChange([event.latlng.lat, event.latlng.lng]), + }) + return null +} + +function LocationPicker({ position, onPositionChange }: LocationPickerProps) { + return ( +
+ + + + {position && } + +
+ ) +} + +export default LocationPicker \ No newline at end of file diff --git a/frontend/src/data/sighting-store.ts b/frontend/src/data/sighting-store.ts new file mode 100644 index 0000000..05c7bb9 --- /dev/null +++ b/frontend/src/data/sighting-store.ts @@ -0,0 +1,25 @@ +import { mockSightings } from './mock-sightings' +import type { UfoSighting } from '../types/ufo-sighting' + +let sightings = [...mockSightings] +const listeners = new Set<() => void>() + +export function getSightings(): UfoSighting[] { + return sightings +} + +export function subscribeToSightings(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function addSighting(sighting: Omit) { + const newSighting: UfoSighting = { + ...sighting, + id: crypto.randomUUID(), + datePosted: new Date().toISOString().slice(0, 10), + } + sightings = [newSighting, ...sightings] + listeners.forEach((listener) => listener()) + return newSighting +} \ No newline at end of file diff --git a/frontend/src/hooks/use-sighting-search.ts b/frontend/src/hooks/use-sighting-search.ts index 5b62139..75fb3f6 100644 --- a/frontend/src/hooks/use-sighting-search.ts +++ b/frontend/src/hooks/use-sighting-search.ts @@ -1,12 +1,12 @@ import { useMemo } from 'react' import { useSearchParams } from 'react-router' -import { mockSightings } from '../data/mock-sightings' import { isSightingSortOption, type SightingSortOption, } from '../types/ufo-sighting' import { searchSightings } from '../utils/filter-sightings' import { useDebouncedValue } from './use-debounced-value' +import { useSightings } from './use-sightings' const SEARCH_DEBOUNCE_MS = 300 const DEFAULT_SORT: SightingSortOption = 'date-desc' @@ -17,6 +17,7 @@ const DEFAULT_SORT: SightingSortOption = 'date-desc' * detail page and back, a page refresh, and can be shared as a link. */ export function useSightingSearch() { + const sightings = useSightings() const [searchParams, setSearchParams] = useSearchParams() const query = searchParams.get('q') ?? '' @@ -43,8 +44,8 @@ export function useSightingSearch() { const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS) const results = useMemo( - () => searchSightings(mockSightings, debouncedQuery, sort), - [debouncedQuery, sort], + () => searchSightings(sightings, debouncedQuery, sort), + [debouncedQuery, sightings, sort], ) return { query, setQuery, sort, setSort, results } diff --git a/frontend/src/hooks/use-sighting.ts b/frontend/src/hooks/use-sighting.ts index 491a7ed..16d5fd2 100644 --- a/frontend/src/hooks/use-sighting.ts +++ b/frontend/src/hooks/use-sighting.ts @@ -1,10 +1,11 @@ -import { mockSightings } from '../data/mock-sightings' import type { UfoSighting } from '../types/ufo-sighting' +import { useSightings } from './use-sightings' /** * Looks up a single sighting by id. Wrapped in a hook so the detail page * won't need to change when this becomes a GraphQL query. */ export function useSighting(id: string | undefined): UfoSighting | undefined { - return mockSightings.find((sighting) => sighting.id === id) + const sightings = useSightings() + return sightings.find((sighting) => sighting.id === id) } diff --git a/frontend/src/hooks/use-sightings.ts b/frontend/src/hooks/use-sightings.ts new file mode 100644 index 0000000..cd90ac3 --- /dev/null +++ b/frontend/src/hooks/use-sightings.ts @@ -0,0 +1,6 @@ +import { useSyncExternalStore } from 'react' +import { getSightings, subscribeToSightings } from '../data/sighting-store' + +export function useSightings() { + return useSyncExternalStore(subscribeToSightings, getSightings, getSightings) +} \ No newline at end of file diff --git a/frontend/src/pages/SubmitSightingPage.css b/frontend/src/pages/SubmitSightingPage.css new file mode 100644 index 0000000..4e7bdaf --- /dev/null +++ b/frontend/src/pages/SubmitSightingPage.css @@ -0,0 +1,120 @@ +.submit-sighting { + max-width: 720px; + margin: 0 auto; +} + +.submit-sighting__back { + color: var(--accent); + text-underline-offset: 4px; +} + +.submit-sighting h1 { + margin-bottom: 16px; +} + +.submit-sighting__intro { + margin-bottom: 28px; +} + +.submit-sighting__location { + display: grid; + gap: 12px; +} + +.submit-sighting__location h2 { + margin-bottom: 4px; +} + +.submit-sighting__form { + display: grid; + gap: 20px; +} + +.submit-sighting__row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.submit-sighting__field { + display: grid; + gap: 6px; +} + +.submit-sighting__field label { + color: var(--text-h); + font-weight: 600; +} + +.submit-sighting__field input, +.submit-sighting__field select, +.submit-sighting__field textarea { + box-sizing: border-box; + width: 100%; + border: 1px solid var(--border); + border-radius: 4px; + padding: 10px 12px; + color: var(--text-h); + background: var(--bg); + font: inherit; +} + +.submit-sighting__field textarea { + resize: vertical; +} + +.submit-sighting__field input:focus-visible, +.submit-sighting__field select:focus-visible, +.submit-sighting__field textarea:focus-visible, +.submit-sighting button:focus-visible, +.submit-sighting__button:focus-visible, +.submit-sighting__back:focus-visible { + outline: 3px solid var(--accent-border); + outline-offset: 2px; +} + +.submit-sighting__field [aria-invalid='true'] { + border-color: #b42318; +} + +.submit-sighting__error, +.submit-sighting__summary { + margin: 0; + color: #b42318; + font-size: 0.9em; +} + +.submit-sighting__summary { + font-weight: 600; +} + +.submit-sighting__button, +.submit-sighting button { + justify-self: start; + border: 0; + border-radius: 4px; + padding: 11px 18px; + color: #fff; + background: var(--accent); + font: inherit; + cursor: pointer; + text-decoration: none; +} + +.submit-sighting__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +.submit-sighting__actions button { + color: var(--text-h); + background: var(--border); +} + +@media (max-width: 640px) { + .submit-sighting__row { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx new file mode 100644 index 0000000..2507d4d --- /dev/null +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -0,0 +1,189 @@ +import { useState, type FormEvent } from 'react' +import { Link } from 'react-router' +import { addSighting } from '../data/sighting-store' +import LocationPicker from '../components/LocationPicker' +import './SubmitSightingPage.css' + +interface FormValues { + datetime: string + city: string + state: string + country: string + shape: string + durationSeconds: string + comments: string + latitude: string + longitude: string +} + +type FormErrors = Partial> + +const initialValues: FormValues = { + datetime: '', + city: '', + state: '', + country: '', + shape: '', + durationSeconds: '', + comments: '', + latitude: '', + longitude: '', +} + +function validate(values: FormValues): FormErrors { + const errors: FormErrors = {} + const duration = Number(values.durationSeconds) + const latitude = Number(values.latitude) + const longitude = Number(values.longitude) + + if (!values.datetime) errors.datetime = 'Enter when you saw the object.' + if (!values.city.trim()) errors.city = 'Enter the city.' + if (!values.country.trim()) errors.country = 'Enter the country code.' + if (!values.shape) errors.shape = 'Choose the closest shape.' + if (!values.durationSeconds || !Number.isFinite(duration) || duration < 0) { + errors.durationSeconds = 'Enter a duration of zero seconds or more.' + } + if (!values.comments.trim()) errors.comments = 'Describe what you saw.' + if (!values.latitude || !Number.isFinite(latitude) || latitude < -90 || latitude > 90) { + errors.latitude = 'Enter a latitude between -90 and 90.' + } + if (!values.longitude || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + errors.longitude = 'Enter a longitude between -180 and 180.' + } + return errors +} + +function SubmitSightingPage() { + const [values, setValues] = useState(initialValues) + const [errors, setErrors] = useState({}) + const [submittedId, setSubmittedId] = useState() + + function updateValue(field: keyof FormValues, value: string) { + setValues((previous) => ({ ...previous, [field]: value })) + setErrors((previous) => ({ ...previous, [field]: undefined })) + } + + function updatePosition([latitude, longitude]: [number, number]) { + updateValue('latitude', latitude.toFixed(6)) + updateValue('longitude', longitude.toFixed(6)) + } + + function handleSubmit(event: FormEvent) { + event.preventDefault() + const nextErrors = validate(values) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + const sighting = addSighting({ + datetime: new Date(values.datetime).toISOString(), + city: values.city.trim(), + state: values.state.trim(), + country: values.country.trim().toLowerCase(), + shape: values.shape, + durationSeconds: Number(values.durationSeconds), + comments: values.comments.trim(), + latitude: Number(values.latitude), + longitude: Number(values.longitude), + }) + setSubmittedId(sighting.id) + } + + if (submittedId) { + return ( +
+ Back to sightings +

Sighting submitted

+

Your sighting has been added to the collection.

+
+ + View your sighting + + +
+
+ ) + } + + return ( +
+ Back to sightings +

Report a sighting

+

Tell us what you saw. Fields marked with an asterisk are required.

+
+
+ + updateValue('datetime', event.target.value)} aria-invalid={Boolean(errors.datetime)} aria-describedby={errors.datetime ? 'sighting-datetime-error' : undefined} required /> + {errors.datetime &&

{errors.datetime}

} +
+
+ + updateValue('city', event.target.value)} aria-invalid={Boolean(errors.city)} aria-describedby={errors.city ? 'sighting-city-error' : undefined} required /> + {errors.city &&

{errors.city}

} +
+
+
+ + updateValue('state', event.target.value)} /> +
+
+ + updateValue('country', event.target.value)} aria-invalid={Boolean(errors.country)} aria-describedby={errors.country ? 'sighting-country-error' : undefined} required /> + {errors.country &&

{errors.country}

} +
+
+
+
+ + + {errors.shape &&

{errors.shape}

} +
+
+ + updateValue('durationSeconds', event.target.value)} aria-invalid={Boolean(errors.durationSeconds)} aria-describedby={errors.durationSeconds ? 'sighting-duration-error' : undefined} required /> + {errors.durationSeconds &&

{errors.durationSeconds}

} +
+
+
+
+ + updateValue('latitude', event.target.value)} aria-invalid={Boolean(errors.latitude)} aria-describedby={errors.latitude ? 'sighting-latitude-error' : undefined} required /> + {errors.latitude &&

{errors.latitude}

} +
+
+ + updateValue('longitude', event.target.value)} aria-invalid={Boolean(errors.longitude)} aria-describedby={errors.longitude ? 'sighting-longitude-error' : undefined} required /> + {errors.longitude &&

{errors.longitude}

} +
+
+
+
+

Where did it happen?

+

Click the map to place the marker, or enter coordinates above.

+
+ +
+
+ +