diff --git a/README.md b/README.md
index 748634b..c35e09c 100644
--- a/README.md
+++ b/README.md
@@ -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
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
index de92545..26173b5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 414769a..a07ce72 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
<>
-
-
+
+ } />
+ } />
+
>
)
diff --git a/frontend/src/components/SightingList.css b/frontend/src/components/SightingList.css
index d65a68b..a5d59c1 100644
--- a/frontend/src/components/SightingList.css
+++ b/frontend/src/components/SightingList.css
@@ -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 {
@@ -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;
@@ -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;
}
}
@@ -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;
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/SightingList.tsx b/frontend/src/components/SightingList.tsx
index 748cebf..341112c 100644
--- a/frontend/src/components/SightingList.tsx
+++ b/frontend/src/components/SightingList.tsx
@@ -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 (
-
-
-
-
- {formatLocation(sighting)}
-
-
-
- )
-}
-
function SightingList({ sightings }: SightingListProps) {
+ const location = useLocation()
+
return (
@@ -86,7 +26,8 @@ function SightingList({ sightings }: SightingListProps) {
{sightings.length === 0 ? (
- No sightings match your search. Try another city, shape or description.
+ No sightings match your search. Try another city, shape or
+ description.
) : (
@@ -95,7 +36,16 @@ function SightingList({ sightings }: SightingListProps) {
-
{formatLocation(sighting)}
+
+ {/* Carries the current search so the detail page can
+ link back to the same results. */}
+
+ {formatLocation(sighting)}
+
+
@@ -120,4 +70,4 @@ function SightingList({ sightings }: SightingListProps) {
)
}
-export default SightingList
\ No newline at end of file
+export default SightingList
diff --git a/frontend/src/components/SightingMap.css b/frontend/src/components/SightingMap.css
new file mode 100644
index 0000000..8a3eb6f
--- /dev/null
+++ b/frontend/src/components/SightingMap.css
@@ -0,0 +1,9 @@
+.sighting-map {
+ overflow: hidden;
+ position: relative;
+}
+
+.sighting-map .leaflet-container {
+ height: 100%;
+ width: 100%;
+}
diff --git a/frontend/src/components/SightingMap.tsx b/frontend/src/components/SightingMap.tsx
new file mode 100644
index 0000000..9af6281
--- /dev/null
+++ b/frontend/src/components/SightingMap.tsx
@@ -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 (
+
+
+
+
+ {formatLocation(sighting)}
+
+
+
+ )
+}
+
+export default SightingMap
diff --git a/frontend/src/hooks/use-sighting-search.ts b/frontend/src/hooks/use-sighting-search.ts
index 3762a36..5b62139 100644
--- a/frontend/src/hooks/use-sighting-search.ts
+++ b/frontend/src/hooks/use-sighting-search.ts
@@ -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
('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(
diff --git a/frontend/src/hooks/use-sighting.ts b/frontend/src/hooks/use-sighting.ts
new file mode 100644
index 0000000..491a7ed
--- /dev/null
+++ b/frontend/src/hooks/use-sighting.ts
@@ -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)
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index eff7ccc..b10eb49 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,10 +1,15 @@
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import "./index.css";
-import App from "./App.tsx";
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { HashRouter } from 'react-router'
+import './index.css'
+import App from './App.tsx'
-createRoot(document.getElementById("root")!).render(
+// Hash URLs (/project2/#/sightings/1) need no server rewrite rules, so
+// refreshing or sharing a deep link works on the course VM as-is.
+createRoot(document.getElementById('root')!).render(
-
+
+
+
,
-);
+)
diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx
new file mode 100644
index 0000000..450ca9a
--- /dev/null
+++ b/frontend/src/pages/SearchPage.tsx
@@ -0,0 +1,21 @@
+import SearchBar from '../components/SearchBar'
+import SightingList from '../components/SightingList'
+import { useSightingSearch } from '../hooks/use-sighting-search'
+
+function SearchPage() {
+ const { query, setQuery, sort, setSort, results } = useSightingSearch()
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default SearchPage
diff --git a/frontend/src/pages/SightingDetailPage.css b/frontend/src/pages/SightingDetailPage.css
new file mode 100644
index 0000000..3e52210
--- /dev/null
+++ b/frontend/src/pages/SightingDetailPage.css
@@ -0,0 +1,69 @@
+.sighting-detail {
+ display: grid;
+ gap: 24px;
+}
+
+.sighting-detail__back {
+ justify-self: start;
+ color: var(--accent);
+ text-underline-offset: 4px;
+
+ &:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+}
+
+.sighting-detail h1 {
+ font-size: 40px;
+ margin: 0 0 4px;
+ text-transform: capitalize;
+}
+
+.sighting-detail__map,
+.sighting-detail__map .leaflet-container {
+ height: 420px;
+ border-radius: 8px;
+}
+
+.sighting-detail__facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 32px;
+ margin: 0;
+
+ div {
+ display: flex;
+ gap: 6px;
+ }
+
+ dt {
+ color: var(--text);
+ }
+
+ dd {
+ margin: 0;
+ color: var(--text-h);
+ text-transform: capitalize;
+ }
+}
+
+.sighting-detail__comments {
+ max-width: 76ch;
+ color: var(--text-h);
+}
+
+@media (max-width: 640px) {
+ .sighting-detail {
+ gap: 16px;
+ }
+
+ .sighting-detail h1 {
+ font-size: 28px;
+ }
+
+ .sighting-detail__map,
+ .sighting-detail__map .leaflet-container {
+ height: 280px;
+ }
+}
diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx
new file mode 100644
index 0000000..d77c0a1
--- /dev/null
+++ b/frontend/src/pages/SightingDetailPage.tsx
@@ -0,0 +1,99 @@
+import { useEffect } from 'react'
+import { Link, useLocation, useParams } from 'react-router'
+import SightingMap from '../components/SightingMap'
+import { useSighting } from '../hooks/use-sighting'
+import {
+ formatDate,
+ formatDuration,
+ formatLocation,
+} from '../utils/format-sighting'
+import './SightingDetailPage.css'
+
+/**
+ * The list passes its search string in navigation state so "Back to
+ * results" restores the same search. Opened directly (e.g. a shared link),
+ * there is no state and the link goes to the unfiltered list.
+ */
+function getFromSearch(state: unknown): string {
+ if (
+ typeof state === 'object' &&
+ state !== null &&
+ 'fromSearch' in state &&
+ typeof state.fromSearch === 'string'
+ ) {
+ return state.fromSearch
+ }
+ return ''
+}
+
+function SightingDetailPage() {
+ const { id } = useParams()
+ const sighting = useSighting(id)
+ const location = useLocation()
+ const backTo = { pathname: '/', search: getFromSearch(location.state) }
+ const title = sighting ? formatLocation(sighting) : 'Sighting not found'
+
+ // Route changes keep the list's scroll position, which would open the
+ // detail page scrolled past its heading.
+ useEffect(() => {
+ window.scrollTo(0, 0)
+ }, [id])
+
+ useEffect(() => {
+ const previousTitle = document.title
+ document.title = `${title} ยท UFOund it`
+ return () => {
+ document.title = previousTitle
+ }
+ }, [title])
+
+ const backLink = (
+
+ โ Back to results
+
+ )
+
+ if (!sighting) {
+ return (
+
+ {backLink}
+
{title}
+
There is no sighting with id โ{id}โ.
+
+ )
+ }
+
+ return (
+
+ {backLink}
+
+ {title}
+
+
+
+
+
+
+
+
- Shape
+ - {sighting.shape}
+
+
+
- Duration
+ - {formatDuration(sighting.durationSeconds)}
+
+
+
+ {sighting.comments}
+
+ )
+}
+
+export default SightingDetailPage
diff --git a/frontend/src/types/ufo-sighting.ts b/frontend/src/types/ufo-sighting.ts
index cd3e2b1..3689dc3 100644
--- a/frontend/src/types/ufo-sighting.ts
+++ b/frontend/src/types/ufo-sighting.ts
@@ -22,5 +22,18 @@ export interface UfoSighting {
longitude: number
}
-export type SightingSortOption =
- 'date-desc' | 'date-asc' | 'city-asc' | 'shape-asc'
+// Kept as a runtime list so values read from the URL can be validated.
+export const sightingSortOptions = [
+ 'date-desc',
+ 'date-asc',
+ 'city-asc',
+ 'shape-asc',
+] as const
+
+export type SightingSortOption = (typeof sightingSortOptions)[number]
+
+export function isSightingSortOption(
+ value: string | null,
+): value is SightingSortOption {
+ return sightingSortOptions.some((option) => option === value)
+}
diff --git a/frontend/src/utils/format-sighting.ts b/frontend/src/utils/format-sighting.ts
new file mode 100644
index 0000000..db18eec
--- /dev/null
+++ b/frontend/src/utils/format-sighting.ts
@@ -0,0 +1,26 @@
+import type { UfoSighting } from '../types/ufo-sighting'
+
+export function formatDate(date: string) {
+ return new Intl.DateTimeFormat('en', {
+ dateStyle: 'medium',
+ }).format(new Date(date))
+}
+
+export 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`
+}
+
+export function formatLocation(sighting: UfoSighting) {
+ const region = [sighting.state, sighting.country]
+ .filter(Boolean)
+ .join(', ')
+ .toUpperCase()
+
+ return region ? `${sighting.city}, ${region}` : sighting.city
+}
diff --git a/package-lock.json b/package-lock.json
index 6788c23..4b44ffc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,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",
@@ -836,6 +837,12 @@
"react-dom": "^19.0.0"
}
},
+ "node_modules/@remix-run/route-pattern": {
+ "version": "0.22.1",
+ "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz",
+ "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==",
+ "license": "MIT"
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz",
@@ -1837,6 +1844,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3281,6 +3294,28 @@
"react-dom": "^19.0.0"
}
},
+ "node_modules/react-router": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz",
+ "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/route-pattern": "^0.22.1",
+ "cookie-es": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=22.22.0"
+ },
+ "peerDependencies": {
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",