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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,36 @@ Before a pull request can be merged:
3. **Pushing new commits dismisses the approval**, so if you change something
after being approved, ask for another review. This is deliberate: what was
approved is what gets merged.

## Project Description
The project is about observed UFO sightings.
Our group found an interesting dataset about observed UFO sightings which is about 80k observations.
This infromation is used to create an application which allows to perform search, and view the infromation about any UFO sighting.
Described further in planned functionality.

## Planned Functionality
The planned functionality is following:

Landing page with one 'Daily' chosen UFO sighting.
The page does display information about the UFO sighting with a map of the location of observation.
Performed search to find UFO sightings.
The user can create a UFO sighting.
For future:
Maybe create an option for comments or up-votes on a UFO sighting.


## Stored User-Generated Data
Created UFO sightings.
TODO


## Outline Technology Choices
TODO


## AI Usage
TODO




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,23 +1,18 @@
import { Route, Routes } from 'react-router'
import Banner from './components/Banner'
import SearchBar from './components/SearchBar'
import SightingList from './components/SightingList'
import { useSightingSearch } from './hooks/use-sighting-search'
import SearchPage from './pages/SearchPage'
import SightingDetailPage from './pages/SightingDetailPage'
import './App.css'

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

return (
<>
<Banner />
<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
Loading