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",