Working on fixing metars, airport layout, etc

This commit is contained in:
2025-06-02 16:54:53 -04:00
parent 7dedc7a8dc
commit 263c33fd5a
24 changed files with 691 additions and 510 deletions

View File

@@ -1,10 +1,12 @@
import {
Accordion,
Badge,
Box, Button,
Box,
Button,
Divider,
Drawer,
Group, Stack,
Group,
Stack,
Tabs,
TabsList,
Text,
@@ -77,15 +79,15 @@ export function AirportDrawer({
>
<Drawer.Content>
<Drawer.Header>
<UnstyledButton
onClick={() => toggleFavorite(airport.icao)}
aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'}
style={{ padding: 4 }}
>
{isFavorite
? <IconStarFilled size={24} color="#faca15" />
: <IconStar size={24} />}
</UnstyledButton>
{user && (
<UnstyledButton
onClick={() => toggleFavorite(airport.icao)}
aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'}
style={{ padding: 4 }}
>
{isFavorite ? <IconStarFilled size={24} color='#faca15' /> : <IconStar size={24} />}
</UnstyledButton>
)}
<Drawer.Title>
<Text size={'xl'}>{airport.name}</Text>
</Drawer.Title>
@@ -117,7 +119,7 @@ export function AirportDrawer({
<TabsList grow>
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
{ user && <Tabs.Tab value={'manage'}>Manage</Tabs.Tab> }
{user && <Tabs.Tab value={'manage'}>Manage</Tabs.Tab>}
</TabsList>
<Tabs.Panel value={'info'}>
<AirportInfo map={map} airport={airport} />
@@ -128,17 +130,17 @@ export function AirportDrawer({
{user && (
<Tabs.Panel value={'manage'}>
{isAdmin ? (
<Stack mt="md">
<Button onClick={() => {}}>Update METAR</Button>
<Button onClick={() => {}}>Edit Airport</Button>
<Button color="red" onClick={() => {}}>
Delete Airport
</Button>
</Stack>
<Stack mt='md'>
<Button onClick={() => {}}>Update METAR</Button>
<Button onClick={() => {}}>Edit Airport</Button>
<Button color='red' onClick={() => {}}>
Delete Airport
</Button>
</Stack>
) : (
<Stack mt="md">
<Button onClick={() => {}}>Request Edit</Button>
</Stack>
<Stack mt='md'>
<Button onClick={() => {}}>Request Edit</Button>
</Stack>
)}
</Tabs.Panel>
)}
@@ -236,7 +238,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
function WeatherInfo({ metar }: { metar?: Metar }) {
if (!metar) {
return <>No METAR observation available/</>
return <>No METAR observation available/</>;
}
return (
<Box>
@@ -262,7 +264,7 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
</Text>
{metar.wind_dir_degrees && metar.wind_speed_kt != null && (
<Text mb="sm">
<Text mb='sm'>
<strong>Wind:</strong> {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt
{metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`}
{metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`}
@@ -270,20 +272,20 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
)}
{metar.visibility_statute_mi && (
<Text mb="sm">
<Text mb='sm'>
<strong>Visibility:</strong> {metar.visibility_statute_mi} statute miles
</Text>
)}
{(metar.temp_c != null || metar.dew_point_c != null) && (
<Text mb="sm">
<Text mb='sm'>
<strong>Temp / Dew Point:</strong> {metar.temp_c}°C / {metar.dew_point_c}°C
{metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`}
</Text>
)}
{(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && (
<Text mb="sm">
<Text mb='sm'>
<strong>Pressure:</strong>
{metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`}
{metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`}
@@ -291,13 +293,13 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
)}
{metar.weather_phenomena.length > 0 && (
<Text mb="sm">
<Text mb='sm'>
<strong>Weather:</strong> {metar.weather_phenomena.join(', ')}
</Text>
)}
{metar.sky_condition.length > 0 && (
<Text mb="sm">
<Text mb='sm'>
<strong>Sky:</strong>{' '}
{metar.sky_condition
.map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`)
@@ -305,19 +307,19 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
</Text>
)}
{(metar.max_temp_c != null && metar.min_temp_c != null) && (
<Text mb="sm">
{metar.max_temp_c != null && metar.min_temp_c != null && (
<Text mb='sm'>
<strong>Max / Min:</strong> {metar.max_temp_c}°C / {metar.min_temp_c}°C
</Text>
)}
{metar.density_altutude != null && (
<Text mb="sm">
<Text mb='sm'>
<strong>Density Altitude:</strong> {metar.density_altutude} ft
</Text>
)}
</Box>
)
);
}
function airportCategoryToText(category: AirportCategory): string {

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import { getAirports } from '@lib/airport.ts';
import { Autocomplete } from '@mantine/core';
export interface AirportSearchProps {
limit?: number;
}
export function AirportSearch({ limit = 5 }: AirportSearchProps) {
const [search, setSearch] = useState('');
const [debounced] = useDebouncedValue(search, 300);
const [data, setData] = useState<{ key: string; value: string; label: string }[]>([]);
useEffect(() => {
if (!debounced) {
setData([]);
return;
}
async function fetch(): Promise<{ key: string; value: string; label: string }[]> {
try {
const icaoResponse = await getAirports({
icaos: [debounced],
limit: 1
});
const nameResponse = await getAirports({
name: debounced,
limit: limit - 1,
});
let combined = [...icaoResponse.data, ...nameResponse.data];
combined = combined.slice(0, limit);
return combined.map((airport) => ({
key: airport.icao,
value: airport.icao,
label: `${airport.icao} - ${airport.name}`,
}));
} catch (err) {
console.error('airport search failed', err);
return []
}
}
fetch().then(d => {
setData(d);
console.log(d)
});
}, [debounced, limit]);
return (
<Autocomplete
placeholder='Enter airport name or ICAO'
value={search}
onChange={setSearch}
data={data}
limit={limit}
onOptionSubmit={() => {}}
radius={'xl'}
onBlur={() => setSearch('')}
/>
);
}

View File

@@ -1,4 +1,4 @@
import { Autocomplete, Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
import classes from './Header.module.css';
import { HeaderModal } from '@components/Header/HeaderModal.tsx';
@@ -6,7 +6,8 @@ import { notifications } from '@mantine/notifications';
import { login, logout, register } from '@lib/account.ts';
import HeaderUser from '@components/Header/HeaderUser.tsx';
import { useUserContext } from '@components/context/UserContext.tsx';
import { Link } from 'react-router';
import { Link, matchPath, useLocation, useNavigate } from 'react-router';
import { AirportSearch } from '@components/AirportSearch.tsx';
// const links = [
// { link: '/', label: 'Map' },
@@ -14,11 +15,18 @@ import { Link } from 'react-router';
// { link: '/metars', label: 'Metars' }
// ];
const protectedPages = [
'/administration',
'/profile'
]
export function Header() {
const { user, setUser } = useUserContext();
const [opened, { toggle }] = useDisclosure(false);
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
const isMobile = useMediaQuery('(max-width: 768px)');
const { pathname } = useLocation();
const navigate = useNavigate();
// const [active, setActive] = useState(links[0].link);
// const navItems = links.map((link) => (
@@ -63,7 +71,14 @@ export function Header() {
async function logoutUser(): Promise<void> {
await logout();
setUser(undefined);
window.location.reload();
// See if the current page is a protected page
const isProtected = protectedPages.some(pattern =>
matchPath(pattern, pathname)
)
if (isProtected) {
navigate('/', { replace: true })
}
}
async function registerUser({
@@ -145,7 +160,8 @@ export function Header() {
{/*</Group>*/}
{!isMobile && (
<Group align='center' gap='xs'>
<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />
<AirportSearch />
{/*<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />*/}
{user ? (
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
) : (

View File

@@ -14,7 +14,7 @@ export const UserContext = createContext<UserContextType>({
setUser: () => {},
loading: true,
favorites: [],
toggleFavorite: () => {},
toggleFavorite: () => {}
});
export function useUserContext(): UserContextType {

View File

@@ -1,6 +1,6 @@
import { ReactNode, useEffect, useState } from 'react';
import { UserContext } from './UserContext.tsx';
import { profile } from '@lib/account.ts';
import { addFavorite, getFavorites, profile, removeFavorite } from '@lib/account.ts';
import { User } from '@lib/account.types.ts';
import { Center, Loader } from '@mantine/core';
import Cookies from 'js-cookie';
@@ -9,19 +9,26 @@ const sessionExpirationName = 'session_expiration';
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | undefined>(undefined);
const [favorites, setFavorites] = useState<string[]>(() => {
return JSON.parse(localStorage.getItem('favorites') || '[]')
})
const [favorites, setFavorites] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const toggleFavorite = (icao: string) => {
async function toggleFavorite(icao: string) {
setFavorites((prev) => {
const next = prev.includes(icao)
const isFav = prev.includes(icao)
const next = isFav
? prev.filter((i) => i !== icao)
: [...prev, icao]
localStorage.setItem('favorites', JSON.stringify(next))
;(isFav
? removeFavorite(icao)
: addFavorite(icao)
).catch((err) => {
console.error('Sync failed, rolling back', err)
setFavorites(prev)
})
return next
})
});
}
useEffect(() => {
@@ -48,6 +55,14 @@ export function UserProvider({ children }: { children: ReactNode }) {
}
}, []);
useEffect(() => {
if (user != undefined) {
getFavorites().then(f => {
setFavorites(f)
})
}
}, [user]);
return (
<UserContext.Provider value={{ user, setUser, loading, favorites, toggleFavorite }}>
{loading ? (

View File

@@ -1,4 +1,4 @@
import { getRequest, postRequest } from '.';
import { deleteRequest, getRequest, postRequest } from '.';
import { RegisterUser, User } from './account.types';
export async function login(username: string, password: string): Promise<User | undefined> {
@@ -31,3 +31,20 @@ export async function profile(): Promise<User | undefined> {
return undefined;
}
}
export async function getFavorites(): Promise<string[]> {
const response = await getRequest('account/profile/favorites');
if (response?.status === 200) {
return response.json();
} else {
return [];
}
}
export async function addFavorite(icao: string): Promise<Response> {
return await postRequest(`account/profile/favorites/${icao}`);
}
export async function removeFavorite(icao: string): Promise<Response> {
return await deleteRequest(`account/profile/favorites/${icao}`);
}

View File

@@ -35,8 +35,8 @@ export async function getAirports({
icaos: icaos ?? undefined,
name: name ?? undefined,
metars: metars ?? undefined,
limit,
page
limit: limit,
page: page
});
return response?.json() || { data: [] };
}

View File

@@ -1,6 +1,6 @@
import { API_URL } from '@lib/constants.ts';
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
export async function getRequest(endpoint: string, params: any = {}): Promise<Response> {
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
const urlParams = new URLSearchParams(params);
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
@@ -11,7 +11,7 @@ export async function getRequest(endpoint: string, params: Record<string, any> =
}
interface PostOptions {
headers?: Record<string, any>;
headers?: Record<string, string>;
type?: 'json' | 'form';
}
@@ -61,9 +61,8 @@ export async function putRequest(endpoint: string, body?: any, options?: PostOpt
export async function deleteRequest(endpoint: string): Promise<Response> {
const url = `${API_URL}/${endpoint}`;
const response = await fetch(url, {
return await fetch(url, {
method: 'DELETE',
credentials: 'include'
});
return response;
}