Fixed layouts

This commit is contained in:
2023-12-21 23:50:39 -05:00
parent 0b2ef94b99
commit bd6d5e03d3
17 changed files with 307 additions and 70 deletions

View File

@@ -144,7 +144,8 @@ pub struct QueryAirport {
#[derive(Debug)] #[derive(Debug)]
pub struct QueryFilters { pub struct QueryFilters {
pub search: Option<String>, pub icaos: Option<Vec<String>>,
pub name: Option<String>,
pub bounds: Option<Polygon<Point>>, pub bounds: Option<Polygon<Point>>,
pub categories: Option<Vec<AirportCategory>>, pub categories: Option<Vec<AirportCategory>>,
pub order_field: Option<QueryOrderField>, pub order_field: Option<QueryOrderField>,
@@ -154,7 +155,8 @@ pub struct QueryFilters {
impl Default for QueryFilters { impl Default for QueryFilters {
fn default() -> Self { fn default() -> Self {
QueryFilters { QueryFilters {
search: None, icaos: None,
name: None,
bounds: None, bounds: None,
categories: None, categories: None,
order_field: None, order_field: None,
@@ -305,11 +307,21 @@ impl QueryAirport {
if let Some(categories) = &filters.categories { if let Some(categories) = &filters.categories {
parts.push(format!("({})", categories.iter().map(|category| format!("category = '{}'", category.to_string())).collect::<Vec<String>>().join(" OR "))); parts.push(format!("({})", categories.iter().map(|category| format!("category = '{}'", category.to_string())).collect::<Vec<String>>().join(" OR ")));
} }
if let Some(search) = &filters.search { fn sanitize_icao(icao: &str) -> String {
// Sanitize search to only allow [a-zA-Z0-9-\\s] // Sanitize search to only allow [a-zA-Z0-9-\\s]
let search = search.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ').collect::<String>(); icao.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == ' ').collect::<String>()
let search_strs = vec!["icao", "name", "iso_country", "iso_region", "municipality"]; }
parts.push(format!("({})", search_strs.iter().map(|s| format!("{} ILIKE '%{}%'", s, search)).collect::<Vec<String>>().join(" OR "))); 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::<Vec<String>>().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::<Vec<String>>().join(" OR ")));
} else if let Some(name) = &filters.name {
let search = sanitize_icao(name);
parts.push(format!("name ILIKE '%{}%'", search));
} }
if parts.len() > 0 { if parts.len() > 0 {

View File

@@ -10,7 +10,8 @@ use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct GetAllParameters { struct GetAllParameters {
search: Option<String>, icaos: Option<String>,
name: Option<String>,
bounds: Option<String>, bounds: Option<String>,
categories: Option<String>, categories: Option<String>,
order_field: Option<String>, order_field: Option<String>,
@@ -68,7 +69,11 @@ async fn import(mut payload: Multipart, auth: JwtAuth) -> HttpResponse {
async fn get_all(req: HttpRequest) -> HttpResponse { async fn get_all(req: HttpRequest) -> HttpResponse {
let params = web::Query::<GetAllParameters>::from_query(req.query_string()).unwrap(); let params = web::Query::<GetAllParameters>::from_query(req.query_string()).unwrap();
let mut filters = QueryFilters::default(); let mut filters = QueryFilters::default();
filters.search = params.search.clone(); filters.icaos = match &params.icaos {
Some(i) => Some(i.split(",").map(|s| s.to_string()).collect()),
None => None
};
filters.name = params.name.clone();
filters.categories = match &params.categories { filters.categories = match &params.categories {
Some(c) => Some(c.split(",").map(|s| AirportCategory::from_str(s).unwrap()).collect()), Some(c) => Some(c.split(",").map(|s| AirportCategory::from_str(s).unwrap()).collect()),
None => None None => None

View File

@@ -13,7 +13,8 @@ export async function getAirport({ icao }: GetAirportProps): Promise<GetAirportR
interface GetAirportsProps { interface GetAirportsProps {
bounds?: Bounds; bounds?: Bounds;
categories?: string[]; categories?: string[];
search?: string; icaos?: string[];
name?: string;
order_field?: AirportOrderField; order_field?: AirportOrderField;
order_by?: 'asc' | 'desc'; order_by?: 'asc' | 'desc';
page?: number; page?: number;
@@ -23,7 +24,8 @@ interface GetAirportsProps {
export async function getAirports({ export async function getAirports({
bounds, bounds,
categories, categories,
search, icaos,
name,
order_field, order_field,
order_by, order_by,
limit = 10, limit = 10,
@@ -34,7 +36,8 @@ export async function getAirports({
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}` ? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
: undefined, : undefined,
categories: categories ?? undefined, categories: categories ?? undefined,
search: search ?? undefined, icaos: icaos ?? undefined,
name: name ?? undefined,
order_field: order_field ?? undefined, order_field: order_field ?? undefined,
order_by: order_by ?? undefined, order_by: order_by ?? undefined,
limit, limit,

View File

@@ -57,19 +57,35 @@ export interface Coordinate {
export interface Airport { export interface Airport {
icao: string; icao: string;
category: AirportCategory; iata: string;
local: string;
name: string; name: string;
elevation_ft: number; category: AirportCategory;
iso_country: string; iso_country: string;
iso_region: string; iso_region: string;
municipality: string; municipality: string;
iata_code: string; elevation_ft: number;
local_code: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
has_tower: boolean;
has_beacon: boolean;
runways: Runway[];
frequencies: Frequency[];
latest_metar?: Metar; latest_metar?: Metar;
} }
export interface Runway {
id: string;
length_ft: number;
width_ft: number;
surface: string;
}
export interface Frequency {
id: string;
frequency_mhz: number;
}
export interface GetAirportResponse { export interface GetAirportResponse {
data: Airport; data: Airport;
meta: Metadata; meta: Metadata;

View File

@@ -4,29 +4,41 @@ import { Airport } from "@/api/airport.types";
import AirportTablePanel from "@/components/Admin/AirportTablePanel"; import AirportTablePanel from "@/components/Admin/AirportTablePanel";
import CreateAirportPanel from "@/components/Admin/CreateAirportPanel"; import CreateAirportPanel from "@/components/Admin/CreateAirportPanel";
import UpdateAirportModal from "@/components/Admin/UpdateAirportModal"; import UpdateAirportModal from "@/components/Admin/UpdateAirportModal";
import { isAdminState } from "@/state/auth";
import { Container, Grid, SimpleGrid } from "@mantine/core"; import { Container, Grid, SimpleGrid } from "@mantine/core";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRecoilValue } from "recoil";
export default function Page() { export default function Page() {
const [airport, setAirport] = useState<Airport | undefined>(undefined); const [airport, setAirport] = useState<Airport | undefined>(undefined);
const isAdmin = useRecoilValue(isAdminState);
const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isAdmin) {
router.push('/');
}
}, [airport]); }, [airport]);
return ( return (
<Container fluid> <>
<SimpleGrid cols={{ base: 1, xs: 1 }} spacing={'md'}> {isAdmin && (
<Grid p={'lg'}> <Container fluid>
<Grid.Col span={12}> <SimpleGrid cols={{ base: 1, xs: 1 }} spacing={'md'}>
<AirportTablePanel setAirport={setAirport} /> <Grid p={'lg'}>
<Grid.Col span={12}>
<AirportTablePanel setAirport={setAirport} />
</Grid.Col>
<Grid.Col span={12}>
<CreateAirportPanel />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> </Grid>
<CreateAirportPanel /> </SimpleGrid>
</Grid.Col> <UpdateAirportModal airport={airport} setAirport={setAirport} />
</Grid> </Container>
</SimpleGrid> )}
<UpdateAirportModal airport={airport} setAirport={setAirport} /> </>
</Container>
); );
} }

View File

@@ -4,7 +4,7 @@ import { getAirport } from '@/api/airport';
import { Airport } from '@/api/airport.types'; import { Airport } from '@/api/airport.types';
import { getMetars } from '@/api/metar'; import { getMetars } from '@/api/metar';
import { Metar } from '@/api/metar.types'; import { Metar } from '@/api/metar.types';
import SkyConditions from '@/components/Metars/SkyConditions'; import { Grid, Title, Text } from '@mantine/core';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export default function Page({ params }: { params: { icao: string } }) { export default function Page({ params }: { params: { icao: string } }) {
@@ -25,12 +25,14 @@ export default function Page({ params }: { params: { icao: string } }) {
if (airport) { if (airport) {
return ( return (
<> <Grid gutter={80} style={{ margin: '1em auto 0'}}>
<div className=''> <Grid.Col span={12}>
<h3 className=''>{airport.name}</h3> <Title className='title' order={1}>{airport.icao} - {airport.name}</Title>
{metar && <SkyConditions metar={metar} />} <Text c="dimmed">
</div> {airport.municipality} | {airport.iso_region} | {airport.iso_country}
</> </Text>
</Grid.Col>
</Grid>
); );
} else { } else {
return <></>; return <></>;

View File

@@ -1,3 +1,159 @@
export default async function Page() { 'use client';
return <></>;
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 (
<Grid gutter={80}>
<Grid.Col span={12}>
<Card shadow="sm" m={"lg"} padding="lg" radius="md" withBorder>
<Title className={classes.title} order={2}>
{user?.first_name} {user?.last_name}
</Title>
<hr />
<Text c="dimmed">
</Text>
</Card>
</Grid.Col>
<Grid.Col span={12}>
<TopSection />
</Grid.Col>
</Grid>
);
}
function TopSection() {
const [airports, setAirports] = useState<Airport[]>([]);
const [metars, setMetars] = useState<Metar[]>([]);
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 (
<Card key={airport.icao} shadow="sm" padding="lg" radius="md" withBorder>
<Group justify="space-between" mt="md" mb="xs">
<Text fw={500} style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', width: '20em' }}>{airport.name}</Text>
<Badge color={color} variant="light">{text}</Badge>
</Group>
<Group style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => {
setCoordinates({
lat: airport.latitude,
lon: airport.longitude,
});
router.push('/');
}}>
<MdLocationSearching size={20} />
<Text size="sm" c="dimmed">
{airport.latitude.toFixed(3)}, {airport.longitude.toFixed(3)}
</Text>
</Group>
<Group style={{
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
}}>
<Button
variant="outline"
color="blue"
size="sm"
radius="lg"
style={{ marginTop: '10px' }}
>
View
</Button>
<Button
variant="outline"
color="red"
size="sm"
radius="lg"
style={{ marginTop: '10px' }}
onClick={async () => {
await removeFavorite(airport.icao);
await updateFavorites();
}}
>
Remove
</Button>
</Group>
</Card>
);
}
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 (
<div className={classes.wrapper}>
<Grid gutter={80}>
<Grid.Col span={{ base: 12, md: 5 }}>
<Title className={classes.title} order={2}>
Logbook
</Title>
<hr />
<Text c="dimmed">
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.
</Text>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<Title className={classes.title} order={2}>
Saved Airports
</Title>
<hr />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={30}>
{airports.map((airport) => AirportCard(airport))}
</SimpleGrid>
</Grid.Col>
</Grid>
</div>
);
} }

View File

@@ -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));
}

View File

@@ -15,7 +15,8 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport
async function getAirportData() { async function getAirportData() {
const response = await getAirports({ const response = await getAirports({
search, icaos: [search],
name: search,
page, page,
limit: 100 limit: 100
}); });

View File

@@ -25,3 +25,9 @@
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
} }
.navbar .user-section {
display: flex;
align-items: center;
padding-right: 2em;
}

View File

@@ -11,6 +11,7 @@ import { useToggle } from '@mantine/hooks';
import { HeaderModal } from './HeaderModal'; import { HeaderModal } from './HeaderModal';
import { coordinatesState } from '@/state/map'; import { coordinatesState } from '@/state/map';
import { User } from '@/api/auth.types'; import { User } from '@/api/auth.types';
import { usePathname, useRouter } from 'next/navigation';
interface HeaderProps { interface HeaderProps {
user: User | undefined; 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 [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']); const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
const [_, setCoordinates] = useRecoilState(coordinatesState); const [_, setCoordinates] = useRecoilState(coordinatesState);
const pathname = usePathname();
const router = useRouter();
async function onChange(value: string) { async function onChange(value: string) {
setSearchValue(value); setSearchValue(value);
const airportData = await getAirports({ search: value }); const airportData = await getAirports({ icaos: [value], name: value });
setAirports( setAirports(
airportData.data.map((airport) => ({ airportData.data.map((airport) => ({
key: airport.icao, key: airport.icao,
@@ -40,9 +43,15 @@ export default function Header({ user, profilePicture, setProfilePicture, login,
} }
async function onClick(value: string) { async function onClick(value: string) {
const airport = await getAirport({ icao: value }); setSearchValue('');
if (airport) { // Get current path
setCoordinates({ lat: airport.data.latitude, lon: airport.data.longitude }); if (pathname == '/') {
const airport = await getAirport({ icao: value });
if (airport) {
setCoordinates({ lat: airport.data.latitude, lon: airport.data.longitude });
}
} else {
router.push(`/airport/${value}`)
} }
} }

View File

@@ -15,7 +15,6 @@ export default function MapTiles() {
const [airports, setAirports] = useState<Airport[]>([]); const [airports, setAirports] = useState<Airport[]>([]);
const [selectedAirport, setSelectedAirport] = useState<Airport | undefined>(); const [selectedAirport, setSelectedAirport] = useState<Airport | undefined>();
const coordinates = useRecoilValue(coordinatesState); const coordinates = useRecoilValue(coordinatesState);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [zoom, setZoom] = useRecoilState(zoomState); const [zoom, setZoom] = useRecoilState(zoomState);
// const [dragging, setDragging] = useState(false); // const [dragging, setDragging] = useState(false);
const map = useMap(); const map = useMap();
@@ -46,7 +45,6 @@ export default function MapTiles() {
async function updateAirports(bounds: LatLngBounds) { async function updateAirports(bounds: LatLngBounds) {
const ne = bounds.getNorthEast(); const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest(); const sw = bounds.getSouthWest();
console.log('zoom', zoom)
const { data: airportData } = await getAirports({ const { data: airportData } = await getAirports({
bounds: { bounds: {
northEast: { lat: ne.lat, lon: ne.lng }, northEast: { lat: ne.lat, lon: ne.lng },

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { MapContainer, useMap } from 'react-leaflet'; import { MapContainer } from 'react-leaflet';
import MapTiles from './MapTiles'; import MapTiles from './MapTiles';
import './metars.css'; import './metars.css';
import { coordinatesState, zoomState } from '@/state/map'; import { coordinatesState, zoomState } from '@/state/map';
@@ -18,8 +18,7 @@ export default function Map() {
maxZoom={14} // Zoomed in maxZoom={14} // Zoomed in
minZoom={3} // Zoomed out minZoom={3} // Zoomed out
id='map-container' id='map-container'
style={{ height: '94.5vh' }} className={`map-container`}
className={`overflow-y-hidden overflow-x-hidden`}
attributionControl={false} attributionControl={false}
> >
<MapTiles /> <MapTiles />

View File

@@ -22,7 +22,7 @@ import './metars.css';
import SkyConditions from './SkyConditions'; import SkyConditions from './SkyConditions';
import { addFavorite, getFavorites, removeFavorite } from '@/api/users'; import { addFavorite, getFavorites, removeFavorite } from '@/api/users';
import { favoritesState } from '@/state/user'; import { favoritesState } from '@/state/user';
import { useRecoilValue } from 'recoil'; import { useRecoilState } from 'recoil';
interface MetarModalProps { interface MetarModalProps {
airport: Airport; airport: Airport;
@@ -31,20 +31,21 @@ interface MetarModalProps {
} }
export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) { export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) {
const favorites = useRecoilValue(favoritesState); const [favorites, setFavorites] = useRecoilState(favoritesState);
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false);
useEffect(() => { useEffect(() => {
setIsFavorite(favorites.includes(airport.icao)); setIsFavorite(favorites.includes(airport.icao));
}, [favorites, airport]); }, [airport, isOpen]);
function handleFavorite(value: boolean) { async function updateIsFavorite(value: boolean) {
setIsFavorite(value); setIsFavorite(value);
if (value) { if (value) {
addFavorite(airport.icao); await addFavorite(airport.icao);
} else { } else {
removeFavorite(airport.icao); await removeFavorite(airport.icao);
} }
setFavorites(await getFavorites());
} }
return ( return (
@@ -60,9 +61,9 @@ export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps
{airport.icao} {airport.name} {airport.icao} {airport.name}
</Link> </Link>
{isFavorite ? ( {isFavorite ? (
<AiFillStar size={24} className='star' onClick={() => handleFavorite(false)} /> <AiFillStar size={24} className='star' onClick={async () => await updateIsFavorite(false)} />
) : ( ) : (
<AiOutlineStar size={24} className='star' onClick={() => handleFavorite(true)} /> <AiOutlineStar size={24} className='star' onClick={async () => await updateIsFavorite(true)} />
)} )}
</span> </span>
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
@@ -171,11 +172,6 @@ function MetarInfo({ metar }: { metar: Metar }) {
<Grid.Col className='gutter-row' span={12}> <Grid.Col className='gutter-row' span={12}>
<Grid gutter={18}> <Grid gutter={18}>
<Grid.Col className='gutter-row' span={12}> <Grid.Col className='gutter-row' span={12}>
<Card shadow='sm' padding='sm' radius='md' style={{ textAlign: 'center' }}>
<Card.Section>
</Card.Section>
</Card>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Grid.Col> </Grid.Col>

View File

@@ -1,14 +1,11 @@
import { Metar } from '@/api/metar.types'; import { Metar } from '@/api/metar.types';
import { Skeleton } from '@mantine/core';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
export default async function Metar() { export default async function Metar() {
const Map = dynamic(() => import('@/components/Metars/MetarMap'), { const Map = dynamic(() => import('@/components/Metars/MetarMap'), {
loading: () => ( loading: () => (
<div className='grid min-h-full place-items-center px-6 py-24 sm:py-32 lg:px-8'> <Skeleton className='map-container' />
<div className='text-center'>
<p className='mt-4 text-3xl font-bold tracking-tight text-gray-300 sm:text-5xl'>Loading...</p>
</div>
</div>
), ),
ssr: false ssr: false
}); });

View File

@@ -16,3 +16,11 @@
.modal .star { .modal .star {
cursor: pointer; cursor: pointer;
} }
.map-container {
/* 100vh - (height of navbar) */
height: calc(100vh - 46px);
width: 100%;
overflow-y: hidden;
overflow-x: hidden;
}

View File

@@ -1,17 +1,20 @@
import { User } from '@/api/auth.types'; import { User } from '@/api/auth.types';
import { atom } from 'recoil'; import { atom, selector } from 'recoil';
export const userState = atom({ export const userState = atom({
key: 'userState', key: 'userState',
default: undefined as User | undefined 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({ export const refreshIdState = atom({
key: 'refreshIdState', key: 'refreshIdState',
default: undefined as NodeJS.Timeout | undefined default: undefined as NodeJS.Timeout | undefined
}); });
export const isAuthenticatedState = atom({
key: 'isAuthenticatedState',
default: false
});