Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -12,6 +13,7 @@ function App() {
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/sightings/:id" element={<SightingDetailPage />} />
<Route path="/submit" element={<SubmitSightingPage />} />
</Routes>
</main>
</>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/Banner.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/Banner.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import './Banner.css'
import { Link } from 'react-router'

function Banner() {
return (
<header className="banner">
<h1 className="banner__title">
<span className="banner__ufo">UFO</span>und it
</h1>
<div className="banner__content">
<Link className="banner__title" to="/">
<span className="banner__ufo">UFO</span>und it
</Link>
<Link className="banner__submit" to="/submit">Report a sighting</Link>
</div>
</header>
)
}
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/LocationPicker.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
52 changes: 52 additions & 0 deletions frontend/src/components/LocationPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<LocationPickerProps, 'onPositionChange'>) {
useMapEvents({
click: (event) => onPositionChange([event.latlng.lat, event.latlng.lng]),
})
return null
}

function LocationPicker({ position, onPositionChange }: LocationPickerProps) {
return (
<div className="location-picker" aria-label="Choose sighting location on map">
<MapContainer
center={[20, 0]}
zoom={2}
minZoom={2}
scrollWheelZoom
keyboard
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickHandler onPositionChange={onPositionChange} />
{position && <Marker position={position} icon={marker} />}
</MapContainer>
</div>
)
}

export default LocationPicker
25 changes: 25 additions & 0 deletions frontend/src/data/sighting-store.ts
Original file line number Diff line number Diff line change
@@ -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<UfoSighting, 'id' | 'datePosted'>) {
const newSighting: UfoSighting = {
...sighting,
id: crypto.randomUUID(),
datePosted: new Date().toISOString().slice(0, 10),
}
sightings = [newSighting, ...sightings]
listeners.forEach((listener) => listener())
return newSighting
}
7 changes: 4 additions & 3 deletions frontend/src/hooks/use-sighting-search.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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') ?? ''
Expand All @@ -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 }
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/hooks/use-sighting.ts
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 6 additions & 0 deletions frontend/src/hooks/use-sightings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useSyncExternalStore } from 'react'
import { getSightings, subscribeToSightings } from '../data/sighting-store'

export function useSightings() {
return useSyncExternalStore(subscribeToSightings, getSightings, getSightings)
}
120 changes: 120 additions & 0 deletions frontend/src/pages/SubmitSightingPage.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading