Overhaul refactor. Still things in progress

This commit is contained in:
2025-04-05 22:42:13 -04:00
parent 310d1eaad8
commit 769762dfa7
133 changed files with 119890 additions and 8784 deletions

View File

@@ -1,175 +0,0 @@
import { Airport, AirportCategory } from '@/api/airport.types';
import { Button, Checkbox, Container, Flex, Group, NumberInput, Paper, Select, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
interface AirportFormProps {
title: string;
airport?: Airport;
submitText: string;
onSubmit: (airport: Airport) => Promise<void>;
onDelete?: () => Promise<void>;
}
export default function AirportForm({ title, airport, submitText, onSubmit, onDelete }: AirportFormProps) {
const form = useForm<Airport>({
initialValues: {
icao: airport?.icao || '',
category: airport?.category || AirportCategory.SMALL,
name: airport?.name || '',
elevation_ft: airport?.elevation_ft || 0,
iso_country: airport?.iso_country || '',
iso_region: airport?.iso_region || '',
municipality: airport?.municipality || '',
iata: airport?.iata || '',
local: airport?.local || '',
latitude: airport?.latitude || 0,
longitude: airport?.longitude || 0,
has_tower: airport?.has_tower || false,
has_beacon: airport?.has_beacon || false,
has_metar: airport?.has_metar || false,
public: airport?.public || false,
runways: airport?.runways || [],
frequencies: airport?.frequencies || [],
}
});
return (
<Container fluid>
<Title ta='center'>{title}</Title>
<Paper p={30} radius={'md'}>
<form onSubmit={form.onSubmit(async (values) => {
await onSubmit(values);
form.reset();
})}>
<Group>
<TextInput
required
label='ICAO'
placeholder='KHEF'
{...form.getInputProps('icao')}
/>
<TextInput
label='IATA Code'
placeholder='HEF'
{...form.getInputProps('iata')}
/>
<TextInput
label='Local Code'
placeholder='HEF'
{...form.getInputProps('local')}
/>
</Group>
<TextInput
required
label='Name'
placeholder='Manassas Regional Airport/Harry P. Davis Field'
{...form.getInputProps('name')}
/>
<Select
required
label='Category'
placeholder='Select category'
data={[
{ value: AirportCategory.SMALL, label: 'Small' },
{ value: AirportCategory.MEDIUM, label: 'Medium' },
{ value: AirportCategory.LARGE, label: 'Large' },
{ value: AirportCategory.HELIPORT, label: 'Heliport' },
{ value: AirportCategory.CLOSED, label: 'Closed' },
{ value: AirportCategory.SEAPLANE, label: 'Seaplane Base' },
{ value: AirportCategory.BALLOONPORT, label: 'Balloonport' },
{ value: AirportCategory.UNKNOWN, label: 'Unknown'}
]}
{...form.getInputProps('category')}
/>
<Group>
<TextInput
required
label='ISO Country'
placeholder='US'
{...form.getInputProps('iso_country')}
/>
<TextInput
required
label='ISO Region'
placeholder='US-VA'
{...form.getInputProps('iso_region')}
/>
<TextInput
required
label='Municipality'
placeholder='Manassas'
{...form.getInputProps('municipality')}
/>
</Group>
<Group>
<Checkbox
mt={'xs'}
label='Has Tower'
defaultChecked={form.values.has_tower}
{...form.getInputProps('has_tower')}
/>
<Checkbox
mt={'xs'}
label='Has Beacon'
defaultChecked={form.values.has_beacon}
{...form.getInputProps('has_beacon')}
/>
<Checkbox
mt={'xs'}
label='Has Metar'
defaultChecked={form.values.has_metar}
{...form.getInputProps('has_metar')}
/>
<Checkbox
mt={'xs'}
label='Public'
defaultChecked={form.values.public}
{...form.getInputProps('public')}
/>
</Group>
<NumberInput
required
hideControls
allowNegative={false}
decimalScale={1}
label='Elevation (ft)'
placeholder='192.2'
{...form.getInputProps('elevation_ft')}
/>
<Group>
<NumberInput
required
hideControls
decimalScale={8}
label='Latitude'
placeholder='38.72140121'
{...form.getInputProps('latitude')}
/>
<NumberInput
required
hideControls
decimalScale={8}
label='Longitude'
placeholder='-77.51540375'
{...form.getInputProps('longitude')}
/>
</Group>
<Flex justify={'end'} mt={'sm'}>
<Button type='submit'>{submitText}</Button>
<Button color='red' ml={'sm'} onClick={() => form.reset()}>Reset</Button>
{onDelete && (
<Button
variant='light'
color='red'
ml={'sm'}
onClick={async () => await onDelete()}
>
Delete
</Button>
)}
</Flex>
</form>
</Paper>
</Container>
)
}

View File

@@ -1,210 +0,0 @@
import { getAirports, importAirports, removeAirport } from "@/api/airport";
import { Airport, airportCategoryToText } from "@/api/airport.types";
import { Text, Button, Card, Group, Pagination, Table, TextInput, rem, UnstyledButton, Center, Flex, Container, Grid, Space, FileButton } from "@mantine/core";
import { HiChevronUp, HiChevronDown, HiSelector } from "react-icons/hi";
import { useEffect, useState } from "react";
import { CiSearch } from "react-icons/ci";
import { notifications } from '@mantine/notifications';
export default function AirportTablePanel({ setShowModal, setAirport }: { setShowModal: (value: boolean) => void, setAirport: (airport: Airport | undefined) => void }) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [airports, setAirports] = useState<Airport[]>([]);
async function getAirportData() {
const response = await getAirports({
icaos: [search],
name: search,
page,
limit: 100
});
setAirports(response.data);
setTotalPages(response.meta.pages);
}
useEffect(() => {
getAirportData();
}, [page, search]);
function handleSearchChange(event: any) {
setSearch(event.currentTarget.value);
}
const rows = airports.map((airport) => (
<Table.Tr
key={airport.icao}
onClick={() => {
setAirport(airport);
setShowModal(true);
}}
style={{ cursor: 'pointer' }}
>
<Table.Td>{airport.icao}</Table.Td>
<Table.Td>{airport.name}</Table.Td>
<Table.Td>{airportCategoryToText(airport.category)}</Table.Td>
<Table.Td>{airport.iso_country}</Table.Td>
<Table.Td>{airport.iso_region}</Table.Td>
<Table.Td>{airport.municipality}</Table.Td>
<Table.Td>{airport.iata}</Table.Td>
<Table.Td>{airport.local}</Table.Td>
</Table.Tr>
))
return <Card shadow={'sm'} padding={'lg'} radius={'md'} withBorder>
<TextInput
placeholder="Search..."
mb="md"
leftSection={<CiSearch style={{ width: rem(16), height: rem(16) }} />}
value={search}
onChange={handleSearchChange}
/>
<Table.ScrollContainer minWidth={500} h={500}>
<Table highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>ICAO</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>ISO Country</Table.Th>
<Table.Th>ISO Region</Table.Th>
<Table.Th>Municipality</Table.Th>
<Table.Th>IATA Code</Table.Th>
<Table.Th>Local Code</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Grid mt={'md'} justify={'space-between'}>
<Grid.Col span={10}>
<Pagination value={page} total={totalPages} onChange={setPage} />
</Grid.Col>
<Grid.Col span={2}>
<Flex justify={'end'}>
<Space mr={'sm'}>
<PanelButton color={'green'} onClick={async () => {
setAirport(undefined);
setShowModal(true);
}}>
Create New
</PanelButton>
</Space>
<Space mr={'sm'}>
<PanelFileButton accept={'.json'} onChange={async (payload) => {
if (payload instanceof File) {
const response = await importAirports(payload);
if (response) {
await getAirportData();
} else {
notifications.show({
title: `Failed to import airports`,
message: `Please try again.`,
color: 'red',
autoClose: 2000
});
}
}
}}>
Import
</PanelFileButton>
</Space>
<Space mr={'sm'}>
<PanelButton color={'blue'} onClick={async () => {
const airports = [];
let page = 1;
let totalPages = 1;
do {
const response = await getAirports({ limit: 1000, page });
airports.push(...response.data);
totalPages = response.meta.pages;
page++;
} while (page <= totalPages);
if (airports && airports.length > 0) {
const element = document.createElement("a");
const file = new Blob([JSON.stringify(airports)], {type: 'text/plain'});
element.href = URL.createObjectURL(file);
element.download = "airports.json";
document.body.appendChild(element); // Required for this to work in FireFox
element.click();
}
}}>
Export
</PanelButton>
</Space>
<Space>
<PanelButton color={'red'} onClick={async () => {
await removeAirport({});
await getAirportData();
}}>
Remove All
</PanelButton>
</Space>
</Flex>
</Grid.Col>
</Grid>
</Card>
}
interface PanelButtonProps {
children: any;
color?: string;
onClick?: () => Promise<void>;
}
interface PanelFileButtonProps {
children: any;
color?: string;
multiple?: boolean;
accept?: string;
onChange?: (payload: File|File[]|null) => Promise<void>;
}
function PanelFileButton({ children, multiple = false, accept, color, onChange = async () => {} }: PanelFileButtonProps) {
const [loading, setLoading] = useState(false);
return <FileButton
multiple={multiple}
accept={accept}
onChange={(e) => {
setLoading(true);
onChange(e).then(() => setLoading(false));
}}
>
{(props) => <Button loading={loading} variant='light' color={color} radius='md' {...props}>{children}</Button>}
</FileButton>
}
function PanelButton({ children, color = 'blue', onClick = async () => {} }: PanelButtonProps) {
const [loading, setLoading] = useState(false);
return <Button
loading={loading}
variant='light'
color={color}
radius={'md'}
onClick={() => {
setLoading(true);
onClick().then(() => setLoading(false));
}}
>
{children}
</Button>
}
function Th({ children, asc, sorted, onSort }: { children: any, asc: boolean, sorted: boolean, onSort: () => void }) {
const Icon = sorted ? (asc ? HiChevronUp : HiChevronDown) : HiSelector;
return (
<Table.Th>
<UnstyledButton onClick={onSort}>
<Group justify="space-between">
<Text fw={500} fz="sm">
{children}
</Text>
<Center>
<Icon style={{ width: rem(16), height: rem(16) }} />
</Center>
</Group>
</UnstyledButton>
</Table.Th>
);
}

View File

@@ -0,0 +1,33 @@
.header {
height: 56px;
margin-bottom: 120px;
background-color: var(--mantine-color-body);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inner {
height: 56px;
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
display: block;
line-height: 1;
padding: 8px 12px;
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
[data-mantine-color-scheme] &[data-active] {
background-color: var(--mantine-color-blue-filled);
color: var(--mantine-color-white);
}
}

View File

@@ -1,212 +0,0 @@
'use client';
import {
Modal,
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
PasswordInput,
Group,
Checkbox,
Text
} from '@mantine/core';
import { useForm } from '@mantine/form';
import Cookies from 'js-cookie';
interface HeaderModalProps {
type?: string;
toggle: any;
login: ({ email, password }: { email: string, password: string }) => Promise<boolean>;
register: ({ firstName, lastName, email, password }: { firstName: string, lastName: string, email: string, password: string }) => Promise<boolean>;
}
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
function passwordValidator(value: string) {
if (value.trim().length < 10) {
return 'Password must be at least 10 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';
}
return null;
}
function emailValidator(value: string) {
if (value.trim().length == 0) {
return 'Email is required';
}
if (!/^\S+@\S+$/.test(value)) {
return 'Invalid email';
}
return null;
}
const registerForm = useForm({
initialValues: {
firstName: '',
lastName: '',
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'),
email: emailValidator,
password: passwordValidator
}
});
const loginForm = useForm({
initialValues: {
email: Cookies.get('email') || '',
password: '',
remember: Cookies.get('remember') === 'true'
}
});
const resetForm = useForm({
initialValues: {
email: ''
}
});
function onClose() {
toggle(undefined);
registerForm.reset();
resetForm.reset();
if (!loginForm.values.remember) {
loginForm.reset();
}
}
return (
<Modal opened={type !== undefined} onClose={onClose} withCloseButton={false}>
{type == 'reset' ? (
<Container>
<Title ta='center'>Reset password</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Enter your email and we will send you a link to reset your password.{' '}
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
Go Back
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form onSubmit={resetForm.onSubmit(async (values) => console.log(values))}>
<TextInput label='Email' placeholder='you@example.com' required {...resetForm.getInputProps('email')} />
<Button type='submit' fullWidth mt='xl'>
Reset password
</Button>
</form>
</Paper>
</Container>
) : type == 'register' ? (
<Container>
<Title ta='center'>Create account</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Already have an account?{' '}
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
Sign in
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={registerForm.onSubmit(async (values) => {
const success = await register(values);
if (success) {
onClose();
}
})}
>
<TextInput label='First name' placeholder='John' required {...registerForm.getInputProps('firstName')} />
<TextInput
label='Last name'
placeholder='Smith'
required
mt='md'
{...registerForm.getInputProps('lastName')}
/>
<TextInput
label='Email'
placeholder='you@example.com'
required
{...registerForm.getInputProps('email')}
/>
<PasswordInput
label='Password'
description='Passwords must be at least 10 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
placeholder='Your password'
required
mt='md'
{...registerForm.getInputProps('password')}
/>
<Button type='submit' fullWidth mt='xl'>
Sign up
</Button>
</form>
</Paper>
</Container>
) : (
<Container>
<Title ta='center'>Welcome back!</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Do not have an account yet?{' '}
<Anchor size='sm' component='a' onClick={() => toggle('register')}>
Create account
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={loginForm.onSubmit(async (values) => {
Cookies.set('remember', 'true', { expires: 365 });
if (values.remember) {
Cookies.set('email', values.email, { expires: 365 });
} else {
Cookies.remove('email');
}
const success = await login(values);
if (success) {
onClose();
}
})}
>
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
<PasswordInput
label='Password'
placeholder='Your password'
required
mt='md'
{...loginForm.getInputProps('password')}
/>
<Group justify='space-between' mt='lg'>
<Checkbox label='Remember me' defaultChecked={loginForm.values.remember} {...loginForm.getInputProps('remember')} />
<Anchor component='a' size='sm' onClick={() => toggle('reset')}>
Forgot password?
</Anchor>
</Group>
<Button type='submit' fullWidth mt='xl'>
Sign in
</Button>
</form>
</Paper>
</Container>
)}
</Modal>
);
}

View File

@@ -1,114 +0,0 @@
import { User } from "@/api/auth.types";
import { setPicture } from "@/api/users";
import {
Menu,
UnstyledButton,
Group,
Avatar,
Card,
FileButton,
Grid,
Button,
Text
} from "@mantine/core";
import Link from "next/link";
import { SetterOrUpdater } from "recoil";
import './styles.css';
interface UserMenuProps {
user: User;
profilePicture: File | undefined;
setProfilePicture: SetterOrUpdater<File | undefined>;
toggle: (type: string) => void;
logout: () => Promise<void>;
}
export default function UserMenu({ user, profilePicture, setProfilePicture, logout, toggle }: UserMenuProps) {
return (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
<Menu.Target>
<UnstyledButton>
<Group>
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div style={{ flex: 1 }}>
<Text size='sm' fw={500}>
{user.first_name} {user.last_name}
</Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
{user.role}
</Text>
</div>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown p={0}>
<Card>
<Card.Section h={140} style={{ backgroundColor: '#4481e3' }} />
<FileButton
onChange={(payload) => {
if (payload) {
setPicture(payload).then((response) => {
if (response) {
setProfilePicture(payload);
}
});
}
}}
accept='image/png,image/jpeg,image/svg+xml,image/webp,image/gif,image/apng,image/avif'
multiple={false}
>
{(props) => (
<Avatar
{...props}
component='button'
size={80}
radius={80}
mx={'auto'}
mt={-30}
style={{ cursor: 'pointer' }}
bg={profilePicture ? 'transparent' : 'white'}
src={profilePicture ? URL.createObjectURL(profilePicture) : undefined}
/>
)}
</FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name}
</Text>
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role}
</Text>
<Grid mt='xl'>
<Grid.Col span={6}>
<Link href='/profile'>
<Button fullWidth radius='md' size='xs' variant='default'>
Profile
</Button>
</Link>
</Grid.Col>
<Grid.Col span={6}>
<Button
fullWidth
radius='md'
size='xs'
variant='default'
onClick={logout}
>
Logout
</Button>
</Grid.Col>
{user.role == 'admin' && (
<Grid.Col span={12}>
<Link href='/admin'>
<Button fullWidth radius='md' size='xs' variant='default'>
Administration
</Button>
</Link>
</Grid.Col>
)}
</Grid>
</Card>
</Menu.Dropdown>
</Menu>
)
}

View File

@@ -1,111 +1,43 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { getAirport, getAirports } from '@/api/airport';
import { Autocomplete, Button, Group, UnstyledButton } from '@mantine/core';
import { SetterOrUpdater, useRecoilState } from 'recoil';
import { useToggle } from '@mantine/hooks';
import { HeaderModal } from './HeaderModal';
import { coordinatesState } from '@/state/map';
import { User } from '@/api/auth.types';
import { usePathname, useRouter } from 'next/navigation';
import { FaMoon } from "react-icons/fa6";
import { FaSun } from "react-icons/fa6";
import UserMenu from './UserMenu';
import './styles.css';
import { Burger, Container, Group, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import classes from './Header.module.css';
interface HeaderProps {
user: User | undefined;
profilePicture: File | undefined;
setProfilePicture: SetterOrUpdater<File | undefined>;
login: ({ email, password }: { email: string, password: string }) => Promise<boolean>;
logout: () => Promise<void>;
register: ({ firstName, lastName, email, password }: { firstName: string, lastName: string, email: string, password: string }) => Promise<boolean>;
}
const links = [
{ link: '/', label: 'Map' },
{ link: '/airports', label: 'Airports' },
{ link: '/metars', label: 'METARs' }
];
export default function Header({ user, profilePicture, setProfilePicture, login, logout, register }: HeaderProps) {
const [searchValue, setSearchValue] = useState('');
const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
const [_, setCoordinates] = useRecoilState(coordinatesState);
const pathname = usePathname();
const router = useRouter();
export function Header() {
const [opened, { toggle }] = useDisclosure(false);
const [active, setActive] = useState(links[0].link);
async function onChange(value: string) {
setSearchValue(value);
const airportData = await getAirports({ icaos: [value], name: value });
setAirports(
airportData.data.map((airport) => ({
key: airport.icao,
value: airport.icao,
label: `${airport.icao} - ${airport.name}`
}))
);
}
async function onClick(value: string) {
setSearchValue('');
// Get current path
if (pathname == '/') {
const airport = await getAirport({ icao: value });
if (airport) {
setCoordinates({ lat: airport.data.latitude, lon: airport.data.longitude });
}
} else {
router.push(`/airport/${value}`)
}
}
const items = links.map((link) => (
<a
key={link.label}
href={link.link}
className={classes.link}
data-active={active === link.link || undefined}
onClick={(event) => {
event.preventDefault();
setActive(link.link);
}}
>
{link.label}
</a>
));
return (
<>
<nav className='navbar'>
<div className='left'>
<Link href={'/'} className='title'>
<span>Aviation Weather</span>
</Link>
<div className='search'>
<Autocomplete
radius='xl'
placeholder='Search Airports...'
data={airports}
limit={10}
value={searchValue}
onChange={onChange}
onOptionSubmit={onClick}
onBlur={() => setSearchValue('')}
/>
</div>
</div>
<div className='right'>
<UnstyledButton style={{ paddingRight: '1em', margin: 'auto' }}>
<FaMoon />
{/* <FaSun /> */}
</UnstyledButton>
{user ? (
<UserMenu
user={user}
profilePicture={profilePicture}
setProfilePicture={setProfilePicture}
toggle={toggle}
logout={logout}
/>
) : (
<Group className='user'>
<Button onClick={() => toggle('login')}>Login</Button>
<Button variant='outline' onClick={() => toggle('register')}>
Sign up
</Button>
</Group>
)}
</div>
</nav>
<HeaderModal
type={modalType}
toggle={toggle}
login={login}
register={register}
/>
</>
<header className={classes.header}>
<Container size='md' className={classes.inner}>
<Text>Aviation Weather</Text>
<Group gap={5} visibleFrom='xs'>
{items}
</Group>
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
</Container>
</header>
);
}

View File

@@ -1,27 +0,0 @@
.navbar {
display: flex;
justify-content: space-between;
height: 46px;
background-color: #242d3e;
color: white;
}
.left {
display: flex;
}
.title {
padding-left: 2em;
padding-right: 2em;
margin: auto;
}
.search {
margin: auto;
}
.right {
display: flex;
align-items: center;
padding-right: 2em;
}

View File

@@ -1,173 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Header from './Header';
import { useRecoilState } from 'recoil';
import { refreshIdState, userState } from '@/state/auth';
import { login, logout, refresh, refreshLoggedIn, register } from '@/api/auth';
import { getFavorites, getPicture } from '@/api/users';
import Cookies from 'js-cookie';
import { favoritesState } from '@/state/user';
import { notifications } from '@mantine/notifications';
import { usePathname, useRouter } from 'next/navigation';
export default function Loader({ children }: { children: any }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useRecoilState(userState);
const [refreshId, setRefreshId] = useRecoilState(refreshIdState);
const [_, setFavorites] = useRecoilState(favoritesState);
const [profilePicture, setProfilePicture] = useState<File | undefined>(undefined);
const path = usePathname();
const router = useRouter();
useEffect(() => {
if (!user || !Cookies.get('logged_in')) {
refreshUser();
}
}, []);
useEffect(() => {
const p = path.split('/');
if (p.length > 1) {
if (p[1] == 'admin' && user?.role != 'admin') {
router.push('/');
} else if (p[1] == 'profile' && !user) {
router.push('/');
}
}
}, [path]);
async function refreshUser() {
setLoading(true);
const response = await refresh();
if (response) {
setRefreshId(refreshLoggedIn());
setUser(response.user);
const favoritesResponse = await getFavorites();
if (favoritesResponse) {
setFavorites(favoritesResponse);
}
if (response.user.profile_picture) {
const pictureResponse = await getPicture();
if (pictureResponse) {
setProfilePicture(pictureResponse as File);
}
}
}
setLoading(false);
}
async function loginUser({ email, password }: { email: string, password: string}): Promise<boolean> {
const loginResponse = await login(email, password);
if (loginResponse) {
setUser(loginResponse.user);
if (loginResponse.user.profile_picture) {
const pictureResponse = await getPicture();
if (pictureResponse) {
setProfilePicture(pictureResponse as File);
}
}
setRefreshId(refreshLoggedIn());
notifications.show({
title: `Welcome back ${loginResponse.user.first_name}!`,
message: `You have been logged in.`,
color: 'green',
autoClose: 2000,
loading: false
});
return true;
} else {
notifications.show({
title: `Unable to Login`,
message: `Please try again.`,
color: 'red',
autoClose: 2000,
loading: false
});
}
return false
}
async function logoutUser(): Promise<void> {
await logout();
Cookies.remove('logged_in');
setUser(undefined);
setFavorites([]);
setProfilePicture(undefined);
if (refreshId) {
clearInterval(refreshId);
setRefreshId(undefined);
}
}
async function registerUser({ firstName, lastName, email, password }: { firstName: string, lastName: string, email: string, password: string }): Promise<boolean> {
const id = notifications.show({
loading: true,
title: `Creating account`,
message: `Please wait...`,
autoClose: false,
withCloseButton: false
});
const registerResponse = await register({
first_name: firstName,
last_name: lastName,
email: email,
password: password
});
if (registerResponse) {
const loginResponse = await login(email, password);
if (loginResponse) {
setUser(loginResponse.user);
if (loginResponse.user.profile_picture) {
const pictureResponse = await getPicture();
if (pictureResponse) {
setProfilePicture(pictureResponse as File);
}
}
setRefreshId(refreshLoggedIn());
notifications.update({
id,
title: `Account created`,
message: `Welcome ${loginResponse.user.first_name}!`,
color: 'green',
autoClose: 2000,
loading: false
});
return true;
} else {
notifications.update({
id,
title: `Unable to Login`,
message: `Please try again.`,
color: 'red',
autoClose: 2000,
loading: false
});
}
} else {
notifications.update({
id,
title: `Unable to Register`,
message: `Please try again.`,
color: 'error',
autoClose: 2000,
loading: false
});
}
return false;
}
return (
<>
{loading ? (
<></>
) : (
<>
<Header user={user} profilePicture={profilePicture} setProfilePicture={setProfilePicture} login={loginUser} logout={logoutUser} register={registerUser} />
{children}
</>
)}
</>
)
}

View File

@@ -1,118 +0,0 @@
'use client';
import { getAirports, updateAirport } from '@/api/airport';
import { Airport, AirportOrderField } from '@/api/airport.types';
import { getMetars } from '@/api/metar';
import { LatLngBounds, PointTuple, icon } from 'leaflet';
import { useEffect, useState } from 'react';
import { Marker, TileLayer, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import MetarModal from './MetarModal';
import { useRecoilState, useRecoilValue } from 'recoil';
import { coordinatesState, zoomState } from '@/state/map';
export default function MapTiles() {
const [isOpen, setIsOpen] = useState(false);
const [airports, setAirports] = useState<Airport[]>([]);
const [selectedAirport, setSelectedAirport] = useState<Airport | undefined>();
const coordinates = useRecoilValue(coordinatesState);
const [zoom, setZoom] = useRecoilState(zoomState);
// const [dragging, setDragging] = useState(false);
const map = useMap();
const mapEvents = useMapEvents({
zoomend: async () => {
setZoom(mapEvents.getZoom());
await updateAirports(mapEvents.getBounds());
},
movestart: () => {
// setDragging(true);
},
moveend: async () => {
// setDragging(false);
await updateAirports(mapEvents.getBounds());
}
});
useEffect(() => {
map.setView([coordinates.lat, coordinates.lon]);
}, [coordinates]);
function handleOpen(airport: Airport) {
setSelectedAirport(airport);
setIsOpen(true);
}
async function updateAirports(bounds: LatLngBounds) {
const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();
const { data: airportData } = await getAirports({
bounds: {
northEast: { lat: ne.lat, lon: ne.lng },
southWest: { lat: sw.lat, lon: sw.lng }
},
categories: ['large_airport', 'medium_airport', 'small_airport'],
order_field: AirportOrderField.CATEGORY,
order_by: 'asc',
limit: zoom < 4 ? 200 : 100,
page: 1
});
const airports = airportData.filter((airport) => airport.has_metar);
const metars = await getMetars(airports.map((a) => a.icao));
metars.forEach((metar) => {
airportData.forEach((airport) => {
if (metar.station_id == airport.icao) {
airport.latest_metar = metar;
}
});
});
setAirports(airportData);
}
function metarIcon(airport: Airport) {
let iconUrl = '/icons/unkn.svg';
let iconSize: PointTuple = [20, 20];
if (!airport.has_metar && airport.latest_metar == undefined) {
iconUrl = '/icons/nometar.svg';
iconSize = [10, 10];
} else if (airport.latest_metar?.flight_category == 'VFR') {
iconUrl = '/icons/vfr.svg';
} else if (airport.latest_metar?.flight_category == 'MVFR') {
iconUrl = '/icons/mvfr.svg';
} else if (airport.latest_metar?.flight_category == 'IFR') {
iconUrl = '/icons/ifr.svg';
} else if (airport.latest_metar?.flight_category == 'LIFR') {
iconUrl = '/icons/lifr.svg';
}
return icon({ iconUrl, iconSize })
}
useEffect(() => {
updateAirports(map.getBounds());
}, []);
return (
<>
{selectedAirport && <MetarModal isOpen={isOpen} onClose={() => setIsOpen(false)} airport={selectedAirport} />}
<TileLayer
attribution='&copy; <a href="https://www.osm.org/copyright">OpenStreetMap</a> contributors'
url='http://{s}.tile.osm.org/{z}/{x}/{y}.png'
/>
{airports.map((airport) => (
<Marker
key={airport.icao}
position={[airport.latitude, airport.longitude]}
icon={metarIcon(airport)}
eventHandlers={{
click: () => handleOpen(airport)
}}
>
{!isOpen && (
<Tooltip className='metar-tooltip' direction='top' offset={[5, -5]} opacity={1}>
<b>{airport.icao}</b> - {airport.name}
</Tooltip>
)}
</Marker>
))}
</>
);
}

View File

@@ -1,28 +0,0 @@
'use client';
import { MapContainer } from 'react-leaflet';
import MapTiles from './MapTiles';
import './metars.css';
import { coordinatesState, zoomState } from '@/state/map';
import { useRecoilValue } from 'recoil';
export default function Map() {
const coordinates = useRecoilValue(coordinatesState);
const zoom = useRecoilValue(zoomState);
return (
<>
<MapContainer
center={[coordinates.lat, coordinates.lon]}
zoom={zoom}
maxZoom={14} // Zoomed in
minZoom={3} // Zoomed out
id='map-container'
className={`map-container`}
attributionControl={false}
>
<MapTiles />
</MapContainer>
</>
);
}

View File

@@ -1,255 +0,0 @@
'use client';
import { Airport } from '@/api/airport.types';
import { Metar } from '@/api/metar.types';
import { FaArrowsSpin, FaLocationArrow } from 'react-icons/fa6';
import Link from 'next/link';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
import {
BsFillCloudDrizzleFill,
BsFillCloudFogFill,
BsFillCloudHailFill,
BsFillCloudHazeFill,
BsFillCloudRainFill,
BsFillCloudRainHeavyFill,
BsFillCloudSleetFill,
BsFillCloudSnowFill,
BsQuestionLg
} from 'react-icons/bs';
import { useEffect, useState } from 'react';
import { Card, Divider, Grid, Modal, Tooltip } from '@mantine/core';
import './metars.css';
import SkyConditions from './SkyConditions';
import { addFavorite, getFavorites, removeFavorite } from '@/api/users';
import { favoritesState } from '@/state/user';
import { useRecoilState } from 'recoil';
interface MetarModalProps {
airport: Airport;
isOpen: boolean;
onClose(): void;
}
export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) {
const [favorites, setFavorites] = useRecoilState(favoritesState);
const [isFavorite, setIsFavorite] = useState(false);
useEffect(() => {
setIsFavorite(favorites.includes(airport.icao));
}, [airport, isOpen]);
async function updateIsFavorite(value: boolean) {
setIsFavorite(value);
if (value) {
await addFavorite(airport.icao);
} else {
await removeFavorite(airport.icao);
}
setFavorites(await getFavorites());
}
return (
<Modal
opened={isOpen}
onClose={onClose}
withCloseButton={false}
size={'50%'}
className='modal'
>
<span className='title'>
<Link href={`/airport/${airport.icao}`}>
{airport.icao} {airport.name}
</Link>
{isFavorite ? (
<AiFillStar size={24} className='star' onClick={async () => await updateIsFavorite(false)} />
) : (
<AiOutlineStar size={24} className='star' onClick={async () => await updateIsFavorite(true)} />
)}
</span>
<div className='min-w-0 flex-1'>
<Divider style={{ paddingTop: '0.1em' }} />
{airport.latest_metar && <MetarInfo metar={airport.latest_metar} />}
</div>
</Modal>
);
}
function MetarInfo({ metar }: { metar: Metar }) {
function metarBGColor(metar: Metar | undefined) {
if (metar?.flight_category == 'VFR') {
return 'green';
} else if (metar?.flight_category == 'MVFR') {
return 'blue';
} else if (metar?.flight_category == 'IFR') {
return 'red';
} else if (metar?.flight_category == 'LIFR') {
return 'purple';
} else {
return 'black';
}
}
function windColor(metar: Metar | undefined) {
if (metar) {
if (Number(metar.wind_speed_kt) <= 9) {
return 'green';
} else if (Number(metar.wind_speed_kt) <= 12) {
return 'orange';
} else {
return 'red';
}
} else {
return 'gray';
}
}
return (
<div>
<p style={{ fontWeight: '200', fontSize: '0.8em', color: 'gray' }}>{metar.raw_text}</p>
<Grid gutter={18}>
<Grid.Col className='gutter-row' span={6} style={{ marginTop: '0.5em' }}>
<Grid.Col span={12}>
<Grid style={{ padding: '2px' }}>
<Grid.Col span={6}>
<Card
shadow='sm'
padding='sm'
radius='md'
style={{
backgroundColor: metarBGColor(metar),
textAlign: 'center',
color: 'white'
}}
>
{metar.flight_category ? metar.flight_category : 'UNKN'}
</Card>
</Grid.Col>
<Grid.Col span={6}>
<>
{metar.wind_speed_kt == undefined || metar.wind_speed_kt == 0 ? (
<Card
shadow='sm'
padding='sm'
radius='md'
style={{ textAlign: 'center', backgroundColor: 'green', color: 'white' }}
>
CALM
</Card>
) : (
<Card shadow='sm' padding='sm' radius='md' style={{ textAlign: 'center' }}>
<Card.Section
style={{
backgroundColor: windColor(metar),
color: 'white'
}}
>
<span style={{ display: 'inline-block' }}>{metar.wind_speed_kt} KT</span>
</Card.Section>
<Card.Section>
{metar.wind_dir_degrees && Number(metar.wind_dir_degrees) > 0 ? (
<>
<FaLocationArrow style={{ rotate: `${-45 + 180 + Number(metar.wind_dir_degrees)}deg` }} />
{metar.wind_dir_degrees}&#176;
</>
) : (
<></>
)}
{metar.wind_dir_degrees && metar.wind_dir_degrees == 'VRB' ? (
<>
<FaArrowsSpin />
VRB
</>
) : (
<></>
)}
</Card.Section>
</Card>
)}
</>
</Grid.Col>
</Grid>
</Grid.Col>
<Grid.Col className='gutter-row' span={12}>
<Grid gutter={18}>
<Grid.Col className='gutter-row' span={12}>
</Grid.Col>
</Grid>
</Grid.Col>
</Grid.Col>
<Grid.Col className='gutter-row' span={6}>
<SkyConditions metar={metar} />
</Grid.Col>
</Grid>
</div>
);
}
function MetarIcon({ wx }: { wx: string }) {
// let color = 'bg-gray-400';
let title = '';
let icon = undefined;
if (wx.includes('DZ')) {
title = 'Drizzle';
icon = <BsFillCloudRainFill />;
} else if (wx.includes('RA')) {
title = 'Rain';
icon = <BsFillCloudRainHeavyFill />;
} else if (wx.includes('SN')) {
title = 'Snow';
icon = <BsFillCloudSnowFill />;
} else if (wx.includes('SG')) {
title = 'Snow Grains';
icon = <BsFillCloudSnowFill />;
} else if (wx.includes('IC')) {
title = 'Ice Crystals';
icon = <BsFillCloudSleetFill />;
} else if (wx.includes('PL')) {
title = 'Ice Pellets';
icon = <BsFillCloudSleetFill />;
} else if (wx.includes('GR')) {
title = 'Hail';
icon = <BsFillCloudHailFill />;
} else if (wx.includes('GS')) {
title = 'Snow Pellets';
icon = <BsFillCloudSleetFill />;
} else if (wx.includes('BR')) {
title = 'Mist';
icon = <BsFillCloudDrizzleFill />;
} else if (wx.includes('FG')) {
title = 'Fog';
icon = <BsFillCloudFogFill />;
} else if (wx.includes('FU')) {
title = 'Smoke';
icon = <BsFillCloudHazeFill />;
} else if (wx.includes('VA')) {
title = 'Volcanic Ash';
icon = <BsFillCloudHazeFill />;
} else if (wx.includes('DU')) {
title = 'Dust';
icon = <BsFillCloudHazeFill />;
} else if (wx.includes('SA')) {
title = 'Sand';
icon = <BsFillCloudHazeFill />;
} else if (wx.includes('HZ')) {
title = 'Haze';
icon = <BsFillCloudHazeFill />;
} else {
title = 'Unknown';
icon = <BsQuestionLg />;
}
return (
<Tooltip label={title}>
<span
style={{
color: 'white',
backgroundColor: 'CornflowerBlue',
borderRadius: '25px',
padding: '0.6em 0.7em 0.6em 0.7em'
}}
>
{icon}
</span>
</Tooltip>
);
}

View File

@@ -1,89 +0,0 @@
'use client';
import { Metar } from '@/api/metar.types';
import { Box, Card, Divider } from '@mantine/core';
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from 'recharts';
export default function SkyConditions({ metar }: { metar: Metar }) {
const data: any = [
{
name: 'start'
},
{
name: 'end'
}
];
if (metar.sky_condition && metar.sky_condition.length > 0 && metar.sky_condition[0].sky_cover != 'CLR') {
let maxHeight = 0;
metar.sky_condition.forEach((skyCondition, index) => {
data[0][index] = skyCondition.cloud_base_ft_agl;
data[1][index] = skyCondition.cloud_base_ft_agl;
if (skyCondition.cloud_base_ft_agl > maxHeight) {
maxHeight = skyCondition.cloud_base_ft_agl;
}
});
maxHeight = Math.ceil((maxHeight % 1000 == 0 ? maxHeight + 1 : maxHeight) / 1000) * 1000;
let interval;
if (maxHeight <= 5000) {
interval = 1;
} else if (maxHeight <= 10000) {
interval = 2;
} else if (maxHeight <= 15000) {
interval = 3;
} else if (maxHeight <= 20000) {
interval = 5;
} else {
interval = 10;
}
return (
<Card padding='lg' radius='md'>
<Divider my='sm' label='Sky Conditions' labelPosition='center' />
<LineChart data={data} width={350} height={300} margin={{ top: 12, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray='3 3' />
<YAxis
includeHidden
ticks={[0, 1000 * interval, 2000 * interval, 3000 * interval, 4000 * interval, 5000 * interval]}
domain={[0, maxHeight]}
/>
<XAxis tick={false} />
{metar.sky_condition.map((skyCondition, index) => (
<Line
key={`sky-condition-line-${index}`}
type={'linear'}
dataKey={index}
dot={false}
isAnimationActive={false}
>
<LabelList
dataKey={index}
position='insideRight'
content={(props) => renderCustomizedLabel(props, skyCondition.sky_cover)}
/>
</Line>
))}
</LineChart>
</Card>
);
} else {
return (
<Card>
<Divider my='sm' label='Sky Conditions' labelPosition='center' />
<Box style={{ width: '350px', height: '300px', textAlign: 'center' }}>Clear Skies</Box>
</Card>
);
}
}
const renderCustomizedLabel = (props: any, skyCover: string) => {
const { x, y, value, index } = props;
if (index == 1) {
return (
<text x={x} y={y - 5} fill={'#285A64'} textAnchor='end'>
{skyCover} {value}
</text>
);
} else {
return <></>;
}
};

View File

@@ -1,13 +0,0 @@
import { Metar } from '@/api/metar.types';
import { Skeleton } from '@mantine/core';
import dynamic from 'next/dynamic';
export default async function Metar() {
const Map = dynamic(() => import('@/components/Metars/MetarMap'), {
loading: () => (
<Skeleton className='map-container' />
),
ssr: false
});
return <Map />;
}

View File

@@ -1,26 +0,0 @@
/* https://stackoverflow.com/questions/55291179/how-to-overlay-content-on-react-leaflet-z-index-problem */
.leaflet-control { z-index: 0 !important}
.leaflet-pane { z-index: 0 !important}
.leaflet-top, .leaflet-bottom {z-index: 0 !important}
.modal {
user-select: none;
}
.modal .title {
display: flex;
width: 100%;
justify-content: space-between;
}
.modal .star {
cursor: pointer;
}
.map-container {
/* 100vh - (height of navbar) */
height: calc(100vh - 46px);
width: 100%;
overflow-y: hidden;
overflow-x: hidden;
}

View File

@@ -1,11 +0,0 @@
.sidebar {
width: 62px;
height: 100%;
display: flex;
flex-direction: column;
.option-group {
display: flex;
flex-direction: column;
}
}

View File

@@ -1,7 +0,0 @@
'use client';
import './Sidebar.css';
export default function Sidebar() {
return <div className=''></div>;
}