diff --git a/service/src/airports/model.rs b/service/src/airports/model.rs index 4bb8137..8dd2e82 100644 --- a/service/src/airports/model.rs +++ b/service/src/airports/model.rs @@ -144,7 +144,8 @@ pub struct QueryAirport { #[derive(Debug)] pub struct QueryFilters { - pub search: Option, + pub icaos: Option>, + pub name: Option, pub bounds: Option>, pub categories: Option>, pub order_field: Option, @@ -154,7 +155,8 @@ pub struct QueryFilters { impl Default for QueryFilters { fn default() -> Self { QueryFilters { - search: None, + icaos: None, + name: None, bounds: None, categories: None, order_field: None, @@ -305,11 +307,21 @@ impl QueryAirport { if let Some(categories) = &filters.categories { parts.push(format!("({})", categories.iter().map(|category| format!("category = '{}'", category.to_string())).collect::>().join(" OR "))); } - if let Some(search) = &filters.search { + fn sanitize_icao(icao: &str) -> String { // Sanitize search to only allow [a-zA-Z0-9-\\s] - let search = search.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ').collect::(); - let search_strs = vec!["icao", "name", "iso_country", "iso_region", "municipality"]; - parts.push(format!("({})", search_strs.iter().map(|s| format!("{} ILIKE '%{}%'", s, search)).collect::>().join(" OR "))); + icao.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ').collect::() + } + if &filters.icaos.is_some() == &true && &filters.name.is_some() == &true { + let icaos = filters.icaos.as_ref().unwrap(); + let name = sanitize_icao(filters.name.as_ref().unwrap()); + let icao_part = format!("({})", icaos.iter().map(|icao| format!("icao ILIKE '{}'", sanitize_icao(icao))).collect::>().join(" OR ")); + let name_part = format!("name ILIKE '%{}%'", name); + parts.push(format!("({} OR {})", icao_part, name_part)); + } else if let Some(icaos) = &filters.icaos { + parts.push(format!("({})", icaos.iter().map(|icao| format!("icao ILIKE '{}'", sanitize_icao(icao))).collect::>().join(" OR "))); + } else if let Some(name) = &filters.name { + let search = sanitize_icao(name); + parts.push(format!("name ILIKE '%{}%'", search)); } if parts.len() > 0 { diff --git a/service/src/airports/routes.rs b/service/src/airports/routes.rs index 9ee0b94..26784c6 100644 --- a/service/src/airports/routes.rs +++ b/service/src/airports/routes.rs @@ -10,7 +10,8 @@ use serde::{Serialize, Deserialize}; #[derive(Debug, Serialize, Deserialize)] struct GetAllParameters { - search: Option, + icaos: Option, + name: Option, bounds: Option, categories: Option, order_field: Option, @@ -68,7 +69,11 @@ async fn import(mut payload: Multipart, auth: JwtAuth) -> HttpResponse { async fn get_all(req: HttpRequest) -> HttpResponse { let params = web::Query::::from_query(req.query_string()).unwrap(); let mut filters = QueryFilters::default(); - filters.search = params.search.clone(); + filters.icaos = match ¶ms.icaos { + Some(i) => Some(i.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.name = params.name.clone(); filters.categories = match ¶ms.categories { Some(c) => Some(c.split(",").map(|s| AirportCategory::from_str(s).unwrap()).collect()), None => None diff --git a/ui/src/api/airport.ts b/ui/src/api/airport.ts index cd1c0fd..ef4490a 100644 --- a/ui/src/api/airport.ts +++ b/ui/src/api/airport.ts @@ -13,7 +13,8 @@ export async function getAirport({ icao }: GetAirportProps): Promise(undefined); + const isAdmin = useRecoilValue(isAdminState); + const router = useRouter(); useEffect(() => { + if (!isAdmin) { + router.push('/'); + } }, [airport]); return ( - - - - - + <> + {isAdmin && ( + + + + + + + + - - - - - - - + + + + + )} + ); } diff --git a/ui/src/app/airport/[icao]/page.tsx b/ui/src/app/airport/[icao]/page.tsx index c5be1a2..7db16c9 100644 --- a/ui/src/app/airport/[icao]/page.tsx +++ b/ui/src/app/airport/[icao]/page.tsx @@ -4,7 +4,7 @@ import { getAirport } from '@/api/airport'; import { Airport } from '@/api/airport.types'; import { getMetars } from '@/api/metar'; import { Metar } from '@/api/metar.types'; -import SkyConditions from '@/components/Metars/SkyConditions'; +import { Grid, Title, Text } from '@mantine/core'; import { useEffect, useState } from 'react'; export default function Page({ params }: { params: { icao: string } }) { @@ -25,12 +25,14 @@ export default function Page({ params }: { params: { icao: string } }) { if (airport) { return ( - <> -
-

{airport.name}

- {metar && } -
- + + + {airport.icao} - {airport.name} + + {airport.municipality} | {airport.iso_region} | {airport.iso_country} + + + ); } else { return <>; diff --git a/ui/src/app/profile/page.tsx b/ui/src/app/profile/page.tsx index 8a71ccc..5f14b9c 100644 --- a/ui/src/app/profile/page.tsx +++ b/ui/src/app/profile/page.tsx @@ -1,3 +1,159 @@ -export default async function Page() { - return <>; +'use client'; + +import { getAirports } from "@/api/airport"; +import { Airport } from "@/api/airport.types"; +import { useEffect, useState } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import { Badge, Button, Card, Grid, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import classes from './profile.module.css'; +import { getFavorites, removeFavorite } from "@/api/users"; +import { getMetars } from "@/api/metar"; +import { Metar } from "@/api/metar.types"; +import { MdLocationSearching } from 'react-icons/md'; +import { useRouter } from "next/navigation"; +import { coordinatesState } from "@/state/map"; +import { userState } from "@/state/auth"; + +export default function Page() { + const user = useRecoilValue(userState); + const router = useRouter(); + + useEffect(() => { + if (!user) { + router.push('/'); + } + }, []); + + return ( + + + + + {user?.first_name} {user?.last_name} + +
+ + + +
+
+ + + +
+ ); +} + +function TopSection() { + const [airports, setAirports] = useState([]); + const [metars, setMetars] = useState([]); + const router = useRouter(); + const [_, setCoordinates] = useRecoilState(coordinatesState); + + useEffect(() => { + updateFavorites(); + }, []); + + function metarColor(metar?: Metar): string { + switch (metar?.flight_category) { + case 'VFR': + return 'green'; + case 'MVFR': + return 'blue'; + case 'IFR': + return 'red'; + case 'LIFR': + return 'purple'; + default: + return 'gray'; + } + } + + function AirportCard(airport: Airport) { + let metar = metars.find((m) => m.station_id === airport.icao); + let color = metarColor(metar); + let text = metar?.flight_category || 'UNKN'; + + return ( + + + {airport.name} + {text} + + { + setCoordinates({ + lat: airport.latitude, + lon: airport.longitude, + }); + router.push('/'); + }}> + + + {airport.latitude.toFixed(3)}, {airport.longitude.toFixed(3)} + + + + + + + + ); + } + + async function updateFavorites() { + const favorites = await getFavorites(); + const m = (await getMetars(favorites)).data; + setMetars(m); + const a = (await getAirports({ icaos: favorites })).data; + setAirports(a); + } + + return ( +
+ + + + Logbook + +
+ + Your logbook is a list of your flights. You can add flights to your logbook by clicking the "Add to logbook" button on the flight page. + +
+ + + Saved Airports + +
+ + {airports.map((airport) => AirportCard(airport))} + +
+
+
+ ); } diff --git a/ui/src/app/profile/profile.module.css b/ui/src/app/profile/profile.module.css new file mode 100644 index 0000000..014353d --- /dev/null +++ b/ui/src/app/profile/profile.module.css @@ -0,0 +1,14 @@ +.wrapper { + padding: calc(var(--mantine-spacing-xl) * 2) var(--mantine-spacing-xl); +} + +.title { + font-family: + Greycliff CF, + var(--mantine-font-family); + font-size: rem(36px); + font-weight: 900; + line-height: 1.1; + margin-bottom: var(--mantine-spacing-md); + color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); +} \ No newline at end of file diff --git a/ui/src/components/Admin/AirportTablePanel.tsx b/ui/src/components/Admin/AirportTablePanel.tsx index 5c2b48b..20547cb 100644 --- a/ui/src/components/Admin/AirportTablePanel.tsx +++ b/ui/src/components/Admin/AirportTablePanel.tsx @@ -15,7 +15,8 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport async function getAirportData() { const response = await getAirports({ - search, + icaos: [search], + name: search, page, limit: 100 }); diff --git a/ui/src/components/Header/header.css b/ui/src/components/Header/header.css index a89ff33..e517c45 100644 --- a/ui/src/components/Header/header.css +++ b/ui/src/components/Header/header.css @@ -24,4 +24,10 @@ padding-right: 2em; margin-top: auto; margin-bottom: auto; +} + +.navbar .user-section { + display: flex; + align-items: center; + padding-right: 2em; } \ No newline at end of file diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 9644683..b1a2e9c 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -11,6 +11,7 @@ import { useToggle } from '@mantine/hooks'; import { HeaderModal } from './HeaderModal'; import { coordinatesState } from '@/state/map'; import { User } from '@/api/auth.types'; +import { usePathname, useRouter } from 'next/navigation'; interface HeaderProps { user: User | undefined; @@ -26,10 +27,12 @@ export default function Header({ user, profilePicture, setProfilePicture, login, const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]); const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']); const [_, setCoordinates] = useRecoilState(coordinatesState); + const pathname = usePathname(); + const router = useRouter(); async function onChange(value: string) { setSearchValue(value); - const airportData = await getAirports({ search: value }); + const airportData = await getAirports({ icaos: [value], name: value }); setAirports( airportData.data.map((airport) => ({ key: airport.icao, @@ -40,9 +43,15 @@ export default function Header({ user, profilePicture, setProfilePicture, login, } async function onClick(value: string) { - const airport = await getAirport({ icao: value }); - if (airport) { - setCoordinates({ lat: airport.data.latitude, lon: airport.data.longitude }); + setSearchValue(''); + // Get current path + if (pathname == '/') { + const airport = await getAirport({ icao: value }); + if (airport) { + setCoordinates({ lat: airport.data.latitude, lon: airport.data.longitude }); + } + } else { + router.push(`/airport/${value}`) } } diff --git a/ui/src/components/Metars/MapTiles.tsx b/ui/src/components/Metars/MapTiles.tsx index 18e3d24..9364bbc 100644 --- a/ui/src/components/Metars/MapTiles.tsx +++ b/ui/src/components/Metars/MapTiles.tsx @@ -15,7 +15,6 @@ export default function MapTiles() { const [airports, setAirports] = useState([]); const [selectedAirport, setSelectedAirport] = useState(); const coordinates = useRecoilValue(coordinatesState); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [zoom, setZoom] = useRecoilState(zoomState); // const [dragging, setDragging] = useState(false); const map = useMap(); @@ -46,7 +45,6 @@ export default function MapTiles() { async function updateAirports(bounds: LatLngBounds) { const ne = bounds.getNorthEast(); const sw = bounds.getSouthWest(); - console.log('zoom', zoom) const { data: airportData } = await getAirports({ bounds: { northEast: { lat: ne.lat, lon: ne.lng }, diff --git a/ui/src/components/Metars/MetarMap.tsx b/ui/src/components/Metars/MetarMap.tsx index e875f53..91d4619 100644 --- a/ui/src/components/Metars/MetarMap.tsx +++ b/ui/src/components/Metars/MetarMap.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MapContainer, useMap } from 'react-leaflet'; +import { MapContainer } from 'react-leaflet'; import MapTiles from './MapTiles'; import './metars.css'; import { coordinatesState, zoomState } from '@/state/map'; @@ -18,8 +18,7 @@ export default function Map() { maxZoom={14} // Zoomed in minZoom={3} // Zoomed out id='map-container' - style={{ height: '94.5vh' }} - className={`overflow-y-hidden overflow-x-hidden`} + className={`map-container`} attributionControl={false} > diff --git a/ui/src/components/Metars/MetarModal.tsx b/ui/src/components/Metars/MetarModal.tsx index 4591c0d..50a0a75 100644 --- a/ui/src/components/Metars/MetarModal.tsx +++ b/ui/src/components/Metars/MetarModal.tsx @@ -22,7 +22,7 @@ import './metars.css'; import SkyConditions from './SkyConditions'; import { addFavorite, getFavorites, removeFavorite } from '@/api/users'; import { favoritesState } from '@/state/user'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState } from 'recoil'; interface MetarModalProps { airport: Airport; @@ -31,20 +31,21 @@ interface MetarModalProps { } export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) { - const favorites = useRecoilValue(favoritesState); + const [favorites, setFavorites] = useRecoilState(favoritesState); const [isFavorite, setIsFavorite] = useState(false); useEffect(() => { setIsFavorite(favorites.includes(airport.icao)); - }, [favorites, airport]); + }, [airport, isOpen]); - function handleFavorite(value: boolean) { + async function updateIsFavorite(value: boolean) { setIsFavorite(value); if (value) { - addFavorite(airport.icao); + await addFavorite(airport.icao); } else { - removeFavorite(airport.icao); + await removeFavorite(airport.icao); } + setFavorites(await getFavorites()); } return ( @@ -60,9 +61,9 @@ export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps {airport.icao} {airport.name} {isFavorite ? ( - handleFavorite(false)} /> + await updateIsFavorite(false)} /> ) : ( - handleFavorite(true)} /> + await updateIsFavorite(true)} /> )}
@@ -171,11 +172,6 @@ function MetarInfo({ metar }: { metar: Metar }) { - - - - - diff --git a/ui/src/components/Metars/index.tsx b/ui/src/components/Metars/index.tsx index dc6c4a2..bbc3b96 100644 --- a/ui/src/components/Metars/index.tsx +++ b/ui/src/components/Metars/index.tsx @@ -1,14 +1,11 @@ import { Metar } from '@/api/metar.types'; +import { Skeleton } from '@mantine/core'; import dynamic from 'next/dynamic'; export default async function Metar() { const Map = dynamic(() => import('@/components/Metars/MetarMap'), { loading: () => ( -
-
-

Loading...

-
-
+ ), ssr: false }); diff --git a/ui/src/components/Metars/metars.css b/ui/src/components/Metars/metars.css index f145770..dcc1b04 100644 --- a/ui/src/components/Metars/metars.css +++ b/ui/src/components/Metars/metars.css @@ -16,3 +16,11 @@ .modal .star { cursor: pointer; } + +.map-container { + /* 100vh - (height of navbar) */ + height: calc(100vh - 46px); + width: 100%; + overflow-y: hidden; + overflow-x: hidden; +} diff --git a/ui/src/state/auth.ts b/ui/src/state/auth.ts index 26efdec..6f118d8 100644 --- a/ui/src/state/auth.ts +++ b/ui/src/state/auth.ts @@ -1,17 +1,20 @@ import { User } from '@/api/auth.types'; -import { atom } from 'recoil'; +import { atom, selector } from 'recoil'; export const userState = atom({ key: 'userState', default: undefined as User | undefined }); +export const isAdminState = selector({ + key: 'isAdminState', + get: ({ get }) => { + const user = get(userState); + return user?.role === 'admin'; + } +}); + export const refreshIdState = atom({ key: 'refreshIdState', default: undefined as NodeJS.Timeout | undefined }); - -export const isAuthenticatedState = atom({ - key: 'isAuthenticatedState', - default: false -});