Working on fixing metars, airport layout, etc
This commit is contained in:
@@ -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 {
|
||||
|
||||
63
ui/src/components/AirportSearch.tsx
Normal file
63
ui/src/components/AirportSearch.tsx
Normal 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('')}
|
||||
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -14,7 +14,7 @@ export const UserContext = createContext<UserContextType>({
|
||||
setUser: () => {},
|
||||
loading: true,
|
||||
favorites: [],
|
||||
toggleFavorite: () => {},
|
||||
toggleFavorite: () => {}
|
||||
});
|
||||
|
||||
export function useUserContext(): UserContextType {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user