Updates to account, ui, etc
This commit is contained in:
@@ -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 (
|
||||
|
||||
25
ui/src/components/AirportDrawer/FrequencyTable.tsx
Normal file
25
ui/src/components/AirportDrawer/FrequencyTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
ui/src/components/AirportDrawer/RunwayTable.tsx
Normal file
27
ui/src/components/AirportDrawer/RunwayTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'n'drop files here to upload. We can accept only <i>.json</i> files that
|
||||
are less than 30mb in size.
|
||||
Drag'n'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
|
||||
.scrolled {
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
75
ui/src/components/CustomControl.tsx
Normal file
75
ui/src/components/CustomControl.tsx
Normal 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;
|
||||
}
|
||||
75
ui/src/components/GroupControl.tsx
Normal file
75
ui/src/components/GroupControl.tsx
Normal 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 click‐blocking 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;
|
||||
}
|
||||
12
ui/src/components/NotFound/Illustration.tsx
Normal file
12
ui/src/components/NotFound/Illustration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/NotFound/NotFound.module.css
Normal file
43
ui/src/components/NotFound/NotFound.module.css
Normal 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);
|
||||
}
|
||||
27
ui/src/components/NotFound/index.tsx
Normal file
27
ui/src/components/NotFound/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user