Updates to account, ui, etc

This commit is contained in:
2025-05-13 22:57:29 -04:00
parent a273d4134b
commit abfa6b534c
38 changed files with 781 additions and 215 deletions

View File

@@ -1,14 +1,14 @@
import { Header } from '@components/Header';
import { Navigate } from 'react-router';
import { useUserContext } from '@components/context/UserContext.tsx';
import { AirportTable } from '@components/AirportTable';
import { AirportDrop } from '@components/AirportDrop';
import { NotFound } from '@components/NotFound';
export function Administration() {
const { user } = useUserContext();
if (user == undefined) {
return <Navigate to={'/'} />;
if (user == undefined || user.role != 'ADMIN') {
return <NotFound />;
}
return (

View File

@@ -0,0 +1,25 @@
import { Table } from '@mantine/core';
import { Frequency } from '@lib/airport.types.ts';
export default function FrequencyTable({ frequencies }: { frequencies: Frequency[] }) {
const rows = frequencies.map((frequency) => (
<Table.Tr key={frequency.id}>
<Table.Td>{frequency.id}</Table.Td>
<Table.Td>{frequency.name}</Table.Td>
<Table.Td>{frequency.frequency_mhz}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>MHz</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,27 @@
import { Table } from '@mantine/core';
import { Runway } from '@lib/airport.types.ts';
export default function RunwayTable({ runways }: { runways: Runway[] }) {
const rows = runways.map((runway) => (
<Table.Tr key={runway.id}>
<Table.Td>{runway.id}</Table.Td>
<Table.Td>{runway.surface}</Table.Td>
<Table.Td>{runway.length_ft}</Table.Td>
<Table.Td>{runway.width_ft}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Surface</Table.Th>
<Table.Th>Length (ft)</Table.Th>
<Table.Th>Width (ft)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}

View File

@@ -1,10 +1,24 @@
import { Badge, Box, Divider, Drawer, Group, Tabs, TabsList, Text, Tooltip, UnstyledButton } from '@mantine/core';
import {
Accordion,
Badge,
Box,
Divider,
Drawer,
Group,
Tabs,
TabsList,
Text,
Tooltip,
UnstyledButton
} from '@mantine/core';
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 FrequencyTable from '@components/AirportDrawer/FrequencyTable.tsx';
export default function Index({
airport,
@@ -155,6 +169,28 @@ function AirportInfo({ airport }: { airport: Airport }) {
</UnstyledButton>
</AirportInfoSlot>
</AirportInfoRow>
<Accordion chevronPosition={'right'} variant={'contained'}>
{airport.runways != null && airport.runways.length > 0 && (
<Accordion.Item value={'runways'}>
<Accordion.Control>
Runways
</Accordion.Control>
<Accordion.Panel>
<RunwayTable runways={airport.runways} />
</Accordion.Panel>
</Accordion.Item>
)}
{airport.frequencies != null && airport.frequencies.length > 0 && (
<Accordion.Item value={'frequencies'}>
<Accordion.Control>
Frequencies
</Accordion.Control>
<Accordion.Panel>
<FrequencyTable frequencies={airport.frequencies} />
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
<Divider />
</div>
);

View File

@@ -25,4 +25,4 @@
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
margin-top: var(--mantine-spacing-xs);
}
}

View File

@@ -20,9 +20,9 @@ export function AirportDrop() {
setLoading(true);
try {
const formData = new FormData();
files.forEach(file => {
files.forEach((file) => {
formData.append('files', file, file.name);
})
});
await importAirports(formData);
} catch (error) {
console.error('Upload error:', error);
@@ -31,12 +31,12 @@ export function AirportDrop() {
}
}}
className={classes.dropzone}
radius="md"
radius='md'
accept={['application/JSON']}
maxSize={30 * 1024 ** 2}
>
<div style={{ pointerEvents: 'none' }}>
<Group justify="center">
<Group justify='center'>
<Dropzone.Accept>
<IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} />
</Dropzone.Accept>
@@ -48,22 +48,22 @@ export function AirportDrop() {
</Dropzone.Idle>
</Group>
<Text ta="center" fw={700} fz="lg" mt="xl">
<Text ta='center' fw={700} fz='lg' mt='xl'>
<Dropzone.Accept>Drop files here</Dropzone.Accept>
<Dropzone.Reject>Json file less than 30mb</Dropzone.Reject>
<Dropzone.Idle>Upload JSON</Dropzone.Idle>
</Text>
<Text className={classes.description}>
Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that
are less than 30mb in size.
Drag&apos;n&apos;drop files here to upload. We can accept only <i>.json</i> files that are less than 30mb in
size.
</Text>
</div>
</Dropzone>
<Button className={classes.control} size="md" radius="xl" onClick={() => openRef.current?.()}>
<Button className={classes.control} size='md' radius='xl' onClick={() => openRef.current?.()}>
Select files
</Button>
</div>
);
}
}

View File

@@ -16,4 +16,4 @@
.scrolled {
box-shadow: var(--mantine-shadow-sm);
}
}

View File

@@ -13,11 +13,11 @@ export function AirportTable() {
useEffect(() => {
const limit = 1000;
getAirports({ page, limit }).then(r => {
getAirports({ page, limit }).then((r) => {
setData(r.data);
setTotalPages(r.total / r.data.length);
});
},[page]);
}, [page]);
const rows = data.map((row, idx) => (
<Table.Tr key={idx}>
@@ -43,14 +43,8 @@ export function AirportTable() {
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ScrollArea>
<Center mt="sm">
<Pagination
value={page}
onChange={setPage}
total={totalPages}
siblings={1}
boundaries={1}
/>
<Center mt='sm'>
<Pagination value={page} onChange={setPage} total={totalPages} siblings={1} boundaries={1} />
</Center>
</>
);

View File

@@ -0,0 +1,75 @@
import { ReactNode, useEffect, useRef } from 'react';
import * as L from 'leaflet';
import { useMap } from 'react-leaflet';
import { createRoot, Root } from 'react-dom/client';
interface Props {
position?: L.ControlPosition;
onClick: () => void;
active?: boolean;
title?: string;
children?: ReactNode;
}
export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) {
const map = useMap();
// Create references
const buttonRef = useRef<HTMLAnchorElement | null>(null);
const reactRootRef = useRef<Root | null>(null);
useEffect(() => {
const ctrl = new L.Control({ position });
ctrl.onAdd = () => {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
const button = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
button.href = '#';
button.title = title;
// Prevent clicks/scrolls on the control from hitting the map
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
// Wire up the handler
L.DomEvent.on(button, 'click', L.DomEvent.stop);
L.DomEvent.on(button, 'click', L.DomEvent.preventDefault);
L.DomEvent.on(button, 'click', () => onClick());
buttonRef.current = button;
// Initial active status
if (active) {
button.classList.add('active');
}
// Render children
if (children) {
reactRootRef.current = createRoot(button);
reactRootRef.current.render(children);
}
return container;
};
// Add component to the map
ctrl.addTo(map);
// On unmount, remove component
return () => {
ctrl.remove();
if (reactRootRef.current) {
reactRootRef.current.unmount();
reactRootRef.current = null;
}
};
}, [map, position, onClick, children, active, title]);
useEffect(() => {
const btn = buttonRef.current;
if (!btn) return;
if (active) btn.classList.add('active');
else btn.classList.remove('active');
}, [active]);
return null;
}

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import { ReactNode } from 'react';
import * as L from 'leaflet';
import { useMap } from 'react-leaflet';
import { createRoot, Root } from 'react-dom/client';
export interface ButtonDef {
title: string;
active: boolean;
onClick: () => void;
icon: ReactNode;
}
interface GroupControlProps {
position?: L.ControlPosition;
buttons: ButtonDef[];
}
export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) {
const map = useMap();
// References
const buttonRefs = useRef<HTMLAnchorElement[]>([]);
const reactRootRefs = useRef<Root[]>([]);
useEffect(() => {
const ctrl = new L.Control({ position });
const current = reactRootRefs.current;
ctrl.onAdd = () => {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
buttons.forEach((btnDef, i) => {
const btn = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
btn.href = '#';
btn.title = btnDef.title;
// standard leaflet clickblocking magic
L.DomEvent.disableClickPropagation(btn);
L.DomEvent.disableScrollPropagation(btn);
L.DomEvent.on(btn, 'click', L.DomEvent.stop)
.on(btn, 'click', L.DomEvent.preventDefault)
.on(btn, 'click', btnDef.onClick);
// Initial active status
if (btnDef.active) btn.classList.add('active');
// Render root
const rootRef = createRoot(btn);
rootRef.render(btnDef.icon);
reactRootRefs.current[i] = rootRef;
buttonRefs.current[i] = btn;
});
return container;
};
ctrl.addTo(map);
return () => {
ctrl.remove();
// unmount React roots
current.forEach((r) => r.unmount());
};
}, [map, buttons, position]);
// if you want to toggle “.active” live when props change
useEffect(() => {
buttons.forEach((b, i) => {
const btn = buttonRefs.current[i];
if (!btn) return;
if (b.active) btn.classList.add('active');
else btn.classList.remove('active');
});
}, [buttons]);
return null;
}

View File

@@ -0,0 +1,12 @@
import { ComponentPropsWithoutRef } from 'react';
export function Illustration(props: ComponentPropsWithoutRef<'svg'>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 362 145' {...props}>
<path
fill='currentColor'
d='M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z'
/>
</svg>
);
}

View File

@@ -0,0 +1,43 @@
.root {
padding-top: 80px;
padding-bottom: 80px;
}
.inner {
position: relative;
}
.image {
position: absolute;
inset: 0;
opacity: 0.75;
color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.content {
padding-top: 220px;
position: relative;
z-index: 1;
@media (max-width: $mantine-breakpoint-sm) {
padding-top: 120px;
}
}
.title {
font-family: Outfit, var(--mantine-font-family);
text-align: center;
font-weight: 500;
font-size: 38px;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 32px;
}
}
.description {
max-width: 540px;
margin: auto;
margin-top: var(--mantine-spacing-xl);
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
}

View File

@@ -0,0 +1,27 @@
import { Button, Container, Group, Text, Title } from '@mantine/core';
import { Illustration } from './Illustration';
import classes from './NotFound.module.css';
import { useNavigate } from 'react-router';
export function NotFound() {
const navigate = useNavigate();
return (
<Container className={classes.root}>
<div className={classes.inner}>
<Illustration className={classes.image} />
<div className={classes.content}>
<Title className={classes.title}>Nothing to see here</Title>
<Text c='dimmed' size='lg' ta='center' className={classes.description}>
Page you are trying to open does not exist. You may have mistyped the address, or the page has been moved to
another URL. If you think this is an error contact support.
</Text>
<Group justify='center'>
<Button size='md' onClick={() => navigate('/')}>
Take me back to home page
</Button>
</Group>
</div>
</div>
</Container>
);
}

View File

@@ -1,12 +1,12 @@
import { Header } from '@components/Header';
import { useUserContext } from '@components/context/UserContext.tsx';
import { Navigate } from 'react-router';
import { NotFound } from '@components/NotFound';
export function Profile() {
const { user } = useUserContext();
if (user == undefined) {
return <Navigate to={'/'} />;
return <NotFound />;
}
return (

View File

@@ -1,6 +1,6 @@
import { ReactNode, useEffect, useState } from 'react';
import { UserContext } from './UserContext.tsx';
import { refresh } from '@lib/account.ts';
import { profile } from '@lib/account.ts';
import { User } from '@lib/account.types.ts';
import { Center, Loader } from '@mantine/core';
@@ -9,7 +9,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
useEffect(() => {
refresh().then((refreshUser) => {
profile().then((refreshUser) => {
if (refreshUser) {
setUser(refreshUser);
} else {