Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"leaflet": "^1.9.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0"
"react-leaflet": "^5.0.0",
"react-router": "^8.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
19 changes: 7 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import SearchBar from './components/SearchBar'
import SightingList from './components/SightingList'
import { useSightingSearch } from './hooks/use-sighting-search'
import { Route, Routes } from 'react-router'
import SearchPage from './pages/SearchPage'
import SightingDetailPage from './pages/SightingDetailPage'
import './App.css'

function App() {
const { query, setQuery, sort, setSort, results } = useSightingSearch()

return (
<main className="app">
<SearchBar
query={query}
onQueryChange={setQuery}
sort={sort}
onSortChange={setSort}
/>
<SightingList sightings={results} />
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/sightings/:id" element={<SightingDetailPage />} />
</Routes>
</main>
)
}
Expand Down
48 changes: 35 additions & 13 deletions frontend/src/components/SightingList.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,15 @@
gap: 24px;
grid-template-columns: 240px minmax(0, 1fr);
padding: 24px 0;
}

.sighting-map {
min-height: 180px;
overflow: hidden;
/* Anchors the heading link's overlay and keeps its z-index (needed to
sit above Leaflet's panes) from leaking outside the item. */
position: relative;
isolation: isolate;
}

.sighting-map .leaflet-container {
height: 100%;
.sighting-list__item .sighting-map,
.sighting-list__item .sighting-map .leaflet-container {
min-height: 180px;
width: 100%;
}

.sighting-list__content {
Expand All @@ -64,6 +61,31 @@
text-transform: capitalize;
}

.sighting-list__topline a {
color: inherit;
text-decoration-color: var(--border);
text-underline-offset: 4px;

/* Stretches the link over the whole item so clicking the map or text
opens the sighting, while it stays a single link for keyboard and
screen reader users. Leaflet's controls use z-index 1000. */
&::after {
content: '';
position: absolute;
inset: 0;
z-index: 1001;
}

&:hover {
text-decoration-color: currentColor;
}

&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}

.sighting-list__topline time {
color: var(--text);
flex: 0 0 auto;
Expand Down Expand Up @@ -119,8 +141,8 @@
grid-template-columns: 140px minmax(0, 1fr);
}

.sighting-map,
.sighting-map .leaflet-container {
.sighting-list__item .sighting-map,
.sighting-list__item .sighting-map .leaflet-container {
min-height: 140px;
}
}
Expand All @@ -130,8 +152,8 @@
grid-template-columns: 1fr;
}

.sighting-map,
.sighting-map .leaflet-container {
.sighting-list__item .sighting-map,
.sighting-list__item .sighting-map .leaflet-container {
min-height: 180px;
}
}
}
94 changes: 22 additions & 72 deletions frontend/src/components/SightingList.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,20 @@
import L from 'leaflet'
import {
MapContainer,
Marker,
Popup,
TileLayer,
} from 'react-leaflet'
import { Link, useLocation } from 'react-router'
import type { UfoSighting } from '../types/ufo-sighting'
import {
formatDate,
formatDuration,
formatLocation,
} from '../utils/format-sighting'
import SightingMap from './SightingMap'
import './SightingList.css'
import 'leaflet/dist/leaflet.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],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})

interface SightingListProps {
sightings: UfoSighting[]
}

function formatDate(date: string) {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
}).format(new Date(date))
}

function formatDuration(seconds: number) {
if (seconds < 60) return `${seconds} sec`

const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (remainingSeconds === 0) return `${minutes} min`

return `${minutes} min ${remainingSeconds} sec`
}

function formatLocation(sighting: UfoSighting) {
const region = [sighting.state, sighting.country]
.filter(Boolean)
.join(', ')
.toUpperCase()

return region ? `${sighting.city}, ${region}` : sighting.city
}

function SightingMap({ sighting }: { sighting: UfoSighting }) {
return (
<div className="sighting-map" aria-label={`Map showing ${sighting.city}`}>
<MapContainer
center={[sighting.latitude, sighting.longitude]}
zoom={10}
scrollWheelZoom={false}
dragging={false}
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[sighting.latitude, sighting.longitude]} icon={marker}>
<Popup>{formatLocation(sighting)}</Popup>
</Marker>
</MapContainer>
</div>
)
}

function SightingList({ sightings }: SightingListProps) {
const location = useLocation()

return (
<section className="sighting-results" aria-labelledby="results-heading">
<div className="sighting-results__header">
Expand All @@ -86,7 +26,8 @@ function SightingList({ sightings }: SightingListProps) {

{sightings.length === 0 ? (
<p className="sighting-results__empty">
No sightings match your search. Try another city, shape or description.
No sightings match your search. Try another city, shape or
description.
</p>
) : (
<ul className="sighting-list">
Expand All @@ -95,7 +36,16 @@ function SightingList({ sightings }: SightingListProps) {
<SightingMap sighting={sighting} />
<article className="sighting-list__content">
<div className="sighting-list__topline">
<h2>{formatLocation(sighting)}</h2>
<h2>
{/* Carries the current search so the detail page can
link back to the same results. */}
<Link
to={`/sightings/${sighting.id}`}
state={{ fromSearch: location.search }}
>
{formatLocation(sighting)}
</Link>
</h2>
<time dateTime={sighting.datetime}>
{formatDate(sighting.datetime)}
</time>
Expand All @@ -120,4 +70,4 @@ function SightingList({ sightings }: SightingListProps) {
)
}

export default SightingList
export default SightingList
9 changes: 9 additions & 0 deletions frontend/src/components/SightingMap.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.sighting-map {
overflow: hidden;
position: relative;
}

.sighting-map .leaflet-container {
height: 100%;
width: 100%;
}
72 changes: 72 additions & 0 deletions frontend/src/components/SightingMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import L from 'leaflet'
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import type { UfoSighting } from '../types/ufo-sighting'
import { formatLocation } from '../utils/format-sighting'
import 'leaflet/dist/leaflet.css'
import './SightingMap.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'

// Leaflet's default icon resolves image paths at runtime, which breaks
// under Vite's bundling, so the icon is built from imported assets.
const marker = L.icon({
iconUrl: markerIcon,
iconRetinaUrl: markerIconRetina,
shadowUrl: markerShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})

interface SightingMapProps {
sighting: UfoSighting
/** Static thumbnails in lists; pannable and zoomable on the detail page. */
interactive?: boolean
zoom?: number
className?: string
}

function SightingMap({
sighting,
interactive = false,
zoom = 10,
className,
}: SightingMapProps) {
const position: [number, number] = [sighting.latitude, sighting.longitude]

return (
<div
className={['sighting-map', className].filter(Boolean).join(' ')}
aria-label={`Map showing ${sighting.city}`}
>
<MapContainer
center={position}
zoom={zoom}
// Scroll-wheel zoom stays off even when interactive so scrolling
// the page over the map doesn't get hijacked.
scrollWheelZoom={false}
dragging={interactive}
zoomControl={interactive}
doubleClickZoom={interactive}
touchZoom={interactive}
boxZoom={interactive}
// Without keyboard handling Leaflet adds no tab stop, so static
// thumbnails don't make keyboard users tab through every map.
keyboard={interactive}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position} icon={marker}>
<Popup>{formatLocation(sighting)}</Popup>
</Marker>
</MapContainer>
</div>
)
}

export default SightingMap
38 changes: 32 additions & 6 deletions frontend/src/hooks/use-sighting-search.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useSearchParams } from 'react-router'
import { mockSightings } from '../data/mock-sightings'
import type { SightingSortOption } from '../types/ufo-sighting'
import {
isSightingSortOption,
type SightingSortOption,
} from '../types/ufo-sighting'
import { searchSightings } from '../utils/filter-sightings'
import { useDebouncedValue } from './use-debounced-value'

const SEARCH_DEBOUNCE_MS = 300
const DEFAULT_SORT: SightingSortOption = 'date-desc'

/**
* Owns the search query and sort order and derives the matching sightings.
* Kept out of the page components so the search bar and the result view
* can share it, and so the data source can later be swapped for GraphQL.
* Both live in the URL (?q=...&sort=...) so they survive navigating to a
* detail page and back, a page refresh, and can be shared as a link.
*/
export function useSightingSearch() {
const [query, setQuery] = useState('')
const [sort, setSort] = useState<SightingSortOption>('date-desc')
const [searchParams, setSearchParams] = useSearchParams()

const query = searchParams.get('q') ?? ''
const sortParam = searchParams.get('sort')
const sort = isSightingSortOption(sortParam) ? sortParam : DEFAULT_SORT

// Replace instead of push so each keystroke doesn't add a history entry.
function updateParam(key: string, value: string, defaultValue: string) {
setSearchParams(
(previous) => {
const next = new URLSearchParams(previous)
if (value === defaultValue) next.delete(key)
else next.set(key, value)
return next
},
{ replace: true },
)
}

const setQuery = (nextQuery: string) => updateParam('q', nextQuery, '')
const setSort = (nextSort: SightingSortOption) =>
updateParam('sort', nextSort, DEFAULT_SORT)

const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS)

const results = useMemo(
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/hooks/use-sighting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { mockSightings } from '../data/mock-sightings'
import type { UfoSighting } from '../types/ufo-sighting'

/**
* 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)
}
Loading