Metar overhaul, added footer, updated schemas
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
26
ui/src/components/Footer/Footer.module.css
Normal file
26
ui/src/components/Footer/Footer.module.css
Normal 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));
|
||||
}
|
||||
53
ui/src/components/Footer/index.tsx
Normal file
53
ui/src/components/Footer/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Profile() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
Todo: profile {user?.first_name}
|
||||
Todo: profile {user?.firstName}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user