Metar overhaul, added footer, updated schemas

This commit is contained in:
2025-05-19 16:09:02 -04:00
parent 2ecb82ae63
commit ed98140d22
54 changed files with 1084 additions and 4924 deletions

View File

@@ -16,6 +16,7 @@ import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
import { GroupControl } from '@components/GroupControl.tsx';
import { AirportDrawer } from '@components/AirportDrawer';
import { LocateControl } from '@components/LocateControl.tsx';
import { Footer } from '@components/Footer';
// Fix Leaflet's default icon path issues with Webpack
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
@@ -139,6 +140,7 @@ function App() {
/>
</MapContainer>
</div>
<Footer />
</div>
);
}

View File

@@ -14,13 +14,13 @@ import {
import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
import { getMetars } from '@lib/metar.ts';
import { useMediaQuery } from '@mantine/hooks';
import { IconViewfinder } from '@tabler/icons-react';
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.tsx';
import { CommunicationTable } from '@components/AirportDrawer/CommunicationTable.tsx';
import { useMap } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import { getMetars } from '@lib/metar.ts';
export function AirportDrawer({
airport,

View File

@@ -5,8 +5,7 @@ import debounce from 'lodash.debounce';
import { getAirports } from '@lib/airport.ts';
import AirportMarker from '@components/AirportMarker.tsx';
import { LayerInfo } from '@/App.tsx';
const EXPANSION_FACTOR = 0.5;
import { LatLng } from 'leaflet';
export default function AirportLayer({
setAirport,
@@ -18,21 +17,13 @@ export default function AirportLayer({
selectedLayer: LayerInfo;
}) {
const [airports, setAirports] = useState<Airport[]>([]);
const lastBoundsRef = useRef<{ ne: any; sw: any } | null>(null);
const lastBoundsRef = useRef<{ ne: LatLng; sw: LatLng } | null>(null);
const debouncedLoad = useRef(
debounce(async (map: any) => {
const b = map.getBounds();
const north = b.getNorth(),
south = b.getSouth();
const east = b.getEast(),
west = b.getWest();
const latDelta = (north - south) * EXPANSION_FACTOR;
const lonDelta = (east - west) * EXPANSION_FACTOR;
// expanded bbox
const ne = { lat: north + latDelta, lon: east + lonDelta };
const sw = { lat: south - latDelta, lon: west - lonDelta };
const bounds = map.getBounds();
const ne = bounds.getNorthEast()
const sw = bounds.getSouthWest()
lastBoundsRef.current = { ne, sw };
try {
@@ -58,7 +49,7 @@ export default function AirportLayer({
return () => {
debouncedLoad.cancel();
};
}, [map]);
}, [map, debouncedLoad]);
return (
<>

View File

@@ -0,0 +1,26 @@
.footer {
background: #32495f;
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inner {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
@media (max-width: $mantine-breakpoint-xs) {
flex-direction: column;
}
}
.link {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
@media (max-width: $mantine-breakpoint-xs) {
margin-top: var(--mantine-spacing-md);
}
}
.link:hover {
color: light-dark(var(--mantine-color-white));
}

View File

@@ -0,0 +1,53 @@
import classes from './Footer.module.css';
import { Divider, Group, Text } from '@mantine/core';
import { useEffect, useState } from 'react';
import { systemInfo } from '@lib/system.ts';
import { useMediaQuery } from '@mantine/hooks';
const links = [
{ link: `/swagger/`, newTab: true, label: 'API Docs' },
{ link: '/cookies', label: 'Cookies' },
{ link: '/privacy', label: 'Privacy' },
{ link: '/terms', label: 'Terms' },
{ link: '/contact', label: 'Contact' }
];
export function Footer() {
const [version, setVersion] = useState('0.0.0');
const isMobile = useMediaQuery('(max-width: 768px)');
const items = links.map((link) => (
<a className={classes.link} key={link.label} href={link.link} target={link.newTab ? `_blank` : ''}>
<Text size='sm'>{link.label}</Text>
</a>
));
useEffect(() => {
systemInfo().then((info) => {
if (info != undefined) {
setVersion(info.version);
}
});
}, []);
return (
<div className={classes.footer}>
<Group className={classes.inner}>
<Group>
<Text size='sm'>
API{' '}
<a className={classes.link} href={'https://gitea.bensherriff.com/bsherriff/aviation'} target={'_blank'}>
v{version}
</a>
</Text>
<Divider orientation={'vertical'} />
<Text size='sm'>© {new Date().getFullYear()} Aviation Data</Text>
</Group>
{!isMobile && (
<Group gap='xs' justify='flex-end' wrap='nowrap'>
{items}
</Group>
)}
</Group>
</div>
);
}

View File

@@ -17,46 +17,46 @@ import Cookies from 'js-cookie';
interface HeaderModalProps {
type?: string;
toggle: (input: string | undefined) => void;
login: ({ email, password }: { email: string; password: string }) => Promise<boolean>;
login: ({ username, password }: { username: string; password: string }) => Promise<boolean>;
register: ({
firstName,
lastName,
email,
username,
password
}: {
firstName: string;
lastName: string;
email: string;
username: string;
password: string;
}) => Promise<boolean>;
}
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
function passwordValidator(value: string) {
if (value.trim().length < 8) {
return 'Password must be at least 8 characters';
if (value.trim().length < 6) {
return 'Password must be at least 6 characters';
}
if (value.trim().length >= 128) {
return 'Password must be at most 128 characters';
}
if (!/(\d)/.test(value)) {
return 'Password must contain at least one number';
}
if (!/[a-z]/.test(value)) {
return 'Password must contain at least one lowercase letter';
}
if (!/[A-Z]/.test(value)) {
return 'Password must contain at least one uppercase letter';
}
if (!/[!@#$%^&*]/.test(value)) {
return 'Password must contain at least one special character';
}
// if (!/(\d)/.test(value)) {
// return 'Password must contain at least one number';
// }
// if (!/[a-z]/.test(value)) {
// return 'Password must contain at least one lowercase letter';
// }
// if (!/[A-Z]/.test(value)) {
// return 'Password must contain at least one uppercase letter';
// }
// if (!/[!@#$%^&*]/.test(value)) {
// return 'Password must contain at least one special character';
// }
return null;
}
function emailValidator(value: string) {
if (value.trim().length == 0) {
return 'Email is required';
return null;
}
if (!/^\S+@\S+$/.test(value)) {
return 'Invalid email';
@@ -68,12 +68,14 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
initialValues: {
firstName: '',
lastName: '',
username: '',
email: '',
password: ''
},
validate: {
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'),
username: (value) => (value.trim().length > 0 ? null : 'Username is required'),
email: emailValidator,
password: passwordValidator
}
@@ -81,7 +83,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
const loginForm = useForm({
initialValues: {
email: Cookies.get('email') || '',
username: Cookies.get('username') || '',
password: '',
remember: Cookies.get('remember') === 'true'
}
@@ -150,14 +152,20 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
{...registerForm.getInputProps('lastName')}
/>
<TextInput
label='Email'
placeholder='you@example.com'
label='Username'
placeholder='Your username'
required
{...registerForm.getInputProps('username')}
/>
<TextInput
label='Email'
description={'Optional for email verification and updates'}
placeholder='you@example.com'
{...registerForm.getInputProps('email')}
/>
<PasswordInput
label='Password'
description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
// description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
placeholder='Your password'
required
mt='md'
@@ -184,9 +192,9 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
onSubmit={loginForm.onSubmit(async (values) => {
Cookies.set('remember', 'true', { expires: 365 });
if (values.remember) {
Cookies.set('email', values.email, { expires: 365 });
Cookies.set('username', values.username, { expires: 365 });
} else {
Cookies.remove('email');
Cookies.remove('username');
}
const success = await login(values);
if (success) {
@@ -194,7 +202,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
}
})}
>
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
<TextInput label='Username' placeholder='Your username' required {...loginForm.getInputProps('username')} />
<PasswordInput
label='Password'
placeholder='Your password'

View File

@@ -21,7 +21,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div tabIndex={-1} style={{ flex: 1, userSelect: 'none' }}>
<Text size='sm' fw={500}>
{user.first_name} {user.last_name}
{user.firstName} {user.lastName}
</Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
{user.role}
@@ -62,7 +62,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
)}
</FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name}
{user.firstName} {user.lastName}
</Text>
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role}

View File

@@ -36,12 +36,12 @@ export function Header() {
// </a>
// ));
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> {
const loginResponse = await login(email, password);
async function loginUser({ username, password }: { username: string; password: string }): Promise<boolean> {
const loginResponse = await login(username, password);
if (loginResponse) {
setUser(loginResponse);
notifications.show({
title: `Welcome back ${loginResponse.first_name}!`,
title: `Welcome back ${loginResponse.firstName}!`,
message: `You have been logged in.`,
color: 'green',
autoClose: 2000,
@@ -69,12 +69,14 @@ export function Header() {
async function registerUser({
firstName,
lastName,
username,
email,
password
}: {
firstName: string;
lastName: string;
email: string;
username: string;
email?: string;
password: string;
}): Promise<boolean> {
const id = notifications.show({
@@ -85,19 +87,20 @@ export function Header() {
withCloseButton: false
});
const registerResponse = await register({
first_name: firstName,
last_name: lastName,
firstName: firstName,
lastName: lastName,
username: username,
email: email,
password: password
});
if (registerResponse) {
const loginResponse = await login(email, password);
const loginResponse = await login(username, password);
if (loginResponse) {
setUser(loginResponse);
notifications.update({
id,
title: `Account created`,
message: `Welcome ${loginResponse.first_name}!`,
message: `Welcome ${loginResponse.firstName}!`,
color: 'green',
autoClose: 2000,
loading: false

View File

@@ -12,7 +12,7 @@ export function Profile() {
return (
<>
<Header />
Todo: profile {user?.first_name}
Todo: profile {user?.firstName}
</>
);
}

View File

@@ -1,8 +1,8 @@
import { getRequest, postRequest } from '.';
import { RegisterUser, User } from './account.types';
export async function login(email: string, password: string): Promise<User | undefined> {
const response = await postRequest('account/login', { email, password });
export async function login(username: string, password: string): Promise<User | undefined> {
const response = await postRequest('account/login', { username, password });
if (response?.status === 200) {
return response.json();
} else {

View File

@@ -1,14 +1,16 @@
export interface RegisterUser {
email: string;
username: string;
email?: string;
password: string;
first_name: string;
last_name: string;
firstName: string;
lastName: string;
}
export interface User {
email_verified: boolean;
username: string;
emailVerified: boolean;
role: string;
first_name: string;
last_name: string;
profile_picture?: string;
firstName: string;
lastName: string;
profilePicture?: string;
}

View File

@@ -29,7 +29,7 @@ export async function getAirports({
}: GetAirportsParameters): Promise<GetAirportsResponse> {
const response = await getRequest('airports', {
bounds: bounds
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
? `${bounds?.northEast.lat},${bounds?.northEast.lng},${bounds?.southWest.lat},${bounds?.southWest.lng}`
: undefined,
categories: categories ?? undefined,
icaos: icaos ?? undefined,

View File

@@ -1,4 +1,5 @@
import { Metar } from './metar.types';
import { LatLng } from 'leaflet';
export enum AirportCategory {
SMALL = 'small_airport',
@@ -12,13 +13,8 @@ export enum AirportCategory {
}
export interface Bounds {
northEast: Coordinate;
southWest: Coordinate;
}
export interface Coordinate {
lat: number;
lon: number;
northEast: LatLng;
southWest: LatLng;
}
export interface Airport {

2
ui/src/lib/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
// @ts-expect-error The window.__CONFIG__ only exists in production
export const API_URL = window.__CONFIG__?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8080/api';

View File

@@ -1,10 +1,9 @@
// @ts-expect-error The window.__CONFIG__ only exists in production
const baseUrl = window.__CONFIG__?.API_URL || import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
import { API_URL } from '@lib/constants.ts';
export async function getRequest(endpoint: string, params: Record<string, 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 ? `${baseUrl}/${endpoint}?${urlParams}` : `${baseUrl}/${endpoint}`;
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
return await fetch(url, {
method: 'GET',
credentials: 'include'
@@ -17,7 +16,7 @@ interface PostOptions {
}
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseUrl}/${endpoint}`;
const url = `${API_URL}/${endpoint}`;
let response;
if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, {
@@ -39,7 +38,7 @@ export async function postRequest(endpoint: string, body?: any, options?: PostOp
}
export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseUrl}/${endpoint}`;
const url = `${API_URL}/${endpoint}`;
let response;
if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, {
@@ -61,7 +60,7 @@ export async function putRequest(endpoint: string, body?: any, options?: PostOpt
}
export async function deleteRequest(endpoint: string): Promise<Response> {
const url = `${baseUrl}/${endpoint}`;
const url = `${API_URL}/${endpoint}`;
const response = await fetch(url, {
method: 'DELETE',
credentials: 'include'

View File

@@ -1,10 +1,16 @@
import { Metar } from '@lib/metar.types.ts';
import { getRequest } from '@lib/index.ts';
import { getRequest, putRequest } from '@lib/index.ts';
export async function getMetars({ icaos, force }: { icaos: string[]; force?: boolean }): Promise<Metar[]> {
export async function getMetars({ icaos }: { icaos: string[] }): Promise<Metar[]> {
const response = await getRequest('metars', {
icaos: icaos,
force: force
icaos: icaos
});
return response?.json() || {};
}
export async function refreshMetars({ icaos }: { icaos: string[] }): Promise<Metar[]> {
const response = await putRequest('metars', {
icaos: icaos
});
return response?.json() || {};
}

11
ui/src/lib/system.ts Normal file
View File

@@ -0,0 +1,11 @@
import { getRequest } from '@lib/index.ts';
import { SystemInfo } from '@lib/system.types.ts';
export async function systemInfo(): Promise<SystemInfo | undefined> {
const response = await getRequest('system/info');
if (response?.status === 200) {
return response.json();
} else {
return undefined;
}
}

View File

@@ -0,0 +1,4 @@
export interface SystemInfo {
version: string;
healthy: boolean;
}