Overhaul refactor. Still things in progress
This commit is contained in:
27
ui/src/App.css
Normal file
27
ui/src/App.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* Ensure that the html and body take up the full height */
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
.App {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Set up Flexbox layout */
|
||||
.App {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh; /* Full viewport height */
|
||||
}
|
||||
|
||||
/* Make the map container fill the remaining space */
|
||||
.leaflet-container {
|
||||
flex: 1 1 auto; /* Allow the map to grow and fill space */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*.leaflet-container {*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100vh;*/
|
||||
/*}*/
|
||||
48
ui/src/App.tsx
Normal file
48
ui/src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { MapContainer, TileLayer } from 'react-leaflet';
|
||||
import '@mantine/core/styles.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './App.css';
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
// import { Header } from '@components/Header';
|
||||
|
||||
// Fix for default marker icon issues in React-Leaflet
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix Leaflet's default icon path issues with Webpack
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
iconUrl: markerIcon,
|
||||
shadowUrl: markerShadow
|
||||
});
|
||||
|
||||
const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className='App'>
|
||||
{/*<Header />*/}
|
||||
<MapContainer
|
||||
className='leaflet-container'
|
||||
center={[38.944444, -77.455833]}
|
||||
zoom={6}
|
||||
minZoom={3}
|
||||
maxZoom={19}
|
||||
maxBounds={[
|
||||
[-85.06, -180],
|
||||
[85.06, 180]
|
||||
]}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer url={tileLayerUrl} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Airport, AirportOrderField, Bounds, GetAirportsResponse } from './airport.types';
|
||||
import { getRequest, deleteRequest, postRequest, putRequest } from '.';
|
||||
|
||||
interface GetAirportProps {
|
||||
icao: string;
|
||||
}
|
||||
|
||||
export async function getAirport({ icao }: GetAirportProps): Promise<Airport> {
|
||||
const response = await getRequest(`airports/${icao}`);
|
||||
return response?.json() || {};
|
||||
}
|
||||
|
||||
interface GetAirportsProps {
|
||||
bounds?: Bounds;
|
||||
categories?: string[];
|
||||
icaos?: string[];
|
||||
name?: string;
|
||||
order_field?: AirportOrderField;
|
||||
order_by?: 'asc' | 'desc';
|
||||
has_metar?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getAirports({
|
||||
bounds,
|
||||
categories,
|
||||
icaos,
|
||||
name,
|
||||
order_field,
|
||||
order_by,
|
||||
has_metar,
|
||||
limit = 10,
|
||||
page = 1
|
||||
}: GetAirportsProps): Promise<GetAirportsResponse> {
|
||||
const response = await getRequest('airports', {
|
||||
bounds: bounds
|
||||
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
|
||||
: undefined,
|
||||
categories: categories ?? undefined,
|
||||
icaos: icaos ?? undefined,
|
||||
name: name ?? undefined,
|
||||
order_field: order_field ?? undefined,
|
||||
order_by: order_by ?? undefined,
|
||||
has_metar: has_metar ?? undefined,
|
||||
limit,
|
||||
page
|
||||
});
|
||||
return response?.json() || { data: [] };
|
||||
}
|
||||
|
||||
export async function removeAirport({ icao }: { icao?: string }): Promise<any> {
|
||||
let response
|
||||
if (icao) {
|
||||
response = await deleteRequest(`airports/${icao}`);
|
||||
} else {
|
||||
response = await deleteRequest('airports');
|
||||
}
|
||||
return response.status == 204;
|
||||
}
|
||||
|
||||
export async function createAirport({ airport }: { airport: Airport }): Promise<any> {
|
||||
const response = await postRequest(`airports`, airport);
|
||||
return response?.json() || { data: undefined };
|
||||
}
|
||||
|
||||
export async function updateAirport({ airport }: { airport: Airport }): Promise<any> {
|
||||
const response = await putRequest(`airports/${airport.icao}`, airport);
|
||||
return response?.json() || { data: undefined };
|
||||
}
|
||||
|
||||
export async function importAirports(payload: File): Promise<boolean> {
|
||||
const data = new FormData();
|
||||
data.append('data', payload);
|
||||
const response = await postRequest('airports/import', data, {
|
||||
type: 'form'
|
||||
});
|
||||
return response ? response.status === 200 : false;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Metadata } from '.';
|
||||
import { Metar } from './metar.types';
|
||||
|
||||
export enum AirportCategory {
|
||||
SMALL = 'small_airport',
|
||||
MEDIUM = 'medium_airport',
|
||||
LARGE = 'large_airport',
|
||||
HELIPORT = 'heliport',
|
||||
BALLOONPORT = 'balloonport',
|
||||
CLOSED = 'closed',
|
||||
SEAPLANE = 'seaplane_base',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
export function airportCategoryToText(category: AirportCategory): string {
|
||||
switch (category) {
|
||||
case AirportCategory.SMALL:
|
||||
return 'Small';
|
||||
case AirportCategory.MEDIUM:
|
||||
return 'Medium';
|
||||
case AirportCategory.LARGE:
|
||||
return 'Large';
|
||||
case AirportCategory.HELIPORT:
|
||||
return 'Helipad';
|
||||
case AirportCategory.CLOSED:
|
||||
return 'Closed';
|
||||
case AirportCategory.SEAPLANE:
|
||||
return 'Seaplane Base';
|
||||
case AirportCategory.BALLOONPORT:
|
||||
return 'Balloonport';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export enum AirportOrderField {
|
||||
ICAO = 'icao',
|
||||
NAME = 'name',
|
||||
CATEGORY = 'category',
|
||||
CONTINENT = 'continent',
|
||||
ISO_COUNTRY = 'iso_country',
|
||||
ISO_REGION = 'iso_region',
|
||||
MUNICIPALITY = 'municipality',
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
northEast: Coordinate;
|
||||
southWest: Coordinate;
|
||||
}
|
||||
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface Airport {
|
||||
icao: string;
|
||||
iata: string;
|
||||
local: string;
|
||||
name: string;
|
||||
category: AirportCategory;
|
||||
iso_country: string;
|
||||
iso_region: string;
|
||||
municipality: string;
|
||||
elevation_ft: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
has_tower: boolean;
|
||||
has_beacon: boolean;
|
||||
has_metar: boolean;
|
||||
public: boolean;
|
||||
runways: Runway[];
|
||||
frequencies: Frequency[];
|
||||
latest_metar?: Metar;
|
||||
}
|
||||
|
||||
export interface Runway {
|
||||
id: string;
|
||||
length_ft: number;
|
||||
width_ft: number;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export interface Frequency {
|
||||
id: string;
|
||||
frequency_mhz: number;
|
||||
}
|
||||
|
||||
export interface GetAirportsResponse {
|
||||
data: Airport[];
|
||||
meta: Metadata;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { getRequest, postRequest } from '.';
|
||||
import { RegisterUser, ResponseAuth } from './auth.types';
|
||||
|
||||
export async function login(email: string, password: string): Promise<ResponseAuth | undefined> {
|
||||
const response = await postRequest('auth/login', { email, password });
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(user: RegisterUser): Promise<boolean> {
|
||||
const response = await postRequest('auth/register', user);
|
||||
if (response?.status === 201) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return await postRequest('auth/logout', {});
|
||||
}
|
||||
|
||||
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> {
|
||||
const response = await getRequest('auth/refresh', { refresh_token_rotation });
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function me(): Promise<ResponseAuth | undefined> {
|
||||
const response = await getRequest('auth/me');
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the logged_in cookie every interval. By default, the interval is 14 minutes.
|
||||
* @param interval
|
||||
* @returns interval id
|
||||
*/
|
||||
export function refreshLoggedIn(interval = 840000) {
|
||||
let loggedIn = Cookies.get('logged_in');
|
||||
const id = setInterval(async () => {
|
||||
const cookie = Cookies.get('logged_in');
|
||||
if (cookie != loggedIn) {
|
||||
loggedIn = cookie;
|
||||
const response = await refresh(true);
|
||||
if (!response) {
|
||||
Cookies.remove('logged_in');
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
return id;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export interface ResponseAuth {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RegisterUser {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
email: string;
|
||||
role: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_picture?: string;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||
const servicePort = process.env.SERVICE_PORT || 5000;
|
||||
const baseURL = `${serviceHost}:${servicePort}`;
|
||||
|
||||
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||
// Remove undefined params
|
||||
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 response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
interface PostOptions {
|
||||
headers?: Record<string, any>;
|
||||
type?: 'json' | 'form';
|
||||
}
|
||||
|
||||
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseURL}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseURL}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function deleteRequest(endpoint: string): Promise<Response> {
|
||||
const url = `${baseURL}/${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
limit: number;
|
||||
page: number;
|
||||
total: number;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Metar } from './metar.types';
|
||||
import { getRequest } from '.';
|
||||
|
||||
export async function getMetars(icaos: string[]): Promise<Metar[]> {
|
||||
if (icaos.length == 0) {
|
||||
return [];
|
||||
}
|
||||
const stationICAOs: string = icaos.map((icao) => icao).join(',');
|
||||
const response = await getRequest(`metars`, { icaos: stationICAOs });
|
||||
return response?.json() || [];
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
export interface SkyCondition {
|
||||
sky_cover: string;
|
||||
cloud_base_ft_agl: number;
|
||||
}
|
||||
|
||||
export interface QualityControlFlags {
|
||||
auto: boolean;
|
||||
auto_station_without_precipitation: boolean;
|
||||
auto_station_with_precipication: boolean;
|
||||
maintenance_indicator_on: boolean;
|
||||
corrected: boolean;
|
||||
}
|
||||
|
||||
export interface RunwayVisualRange {
|
||||
runway: string;
|
||||
visibility_ft: string;
|
||||
variable_visibility_high_ft: string;
|
||||
variable_visibility_low_ft: string;
|
||||
}
|
||||
|
||||
export interface Metar {
|
||||
raw_text: string;
|
||||
station_id: string;
|
||||
observation_time: string;
|
||||
temp_c: number;
|
||||
dewpoint_c: number;
|
||||
wind_dir_degrees: string;
|
||||
wind_speed_kt: number;
|
||||
wind_gust_kt: number;
|
||||
variable_wind_dir_degrees: string;
|
||||
visibility_statute_mi: string;
|
||||
runway_visual_range: RunwayVisualRange[];
|
||||
altim_in_hg: number;
|
||||
sea_level_pressure_mb: number;
|
||||
quality_control_flags: QualityControlFlags;
|
||||
weather_phenomena: string[];
|
||||
sky_condition: SkyCondition[];
|
||||
flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN';
|
||||
three_hr_pressure_tendency_mb: number;
|
||||
max_t_c: number;
|
||||
min_t_c: number;
|
||||
precip_in: number;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { deleteRequest, getRequest, postRequest } from '.';
|
||||
|
||||
export async function getPicture(): Promise<Blob | undefined> {
|
||||
const response = await getRequest('users/picture');
|
||||
if (response?.status === 200) {
|
||||
return await response.blob();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPicture(payload: File): Promise<boolean> {
|
||||
const data = new FormData();
|
||||
data.append('data', payload);
|
||||
// TODO: Figure out why the form data object is empty
|
||||
const response = await postRequest('users/picture', data, {
|
||||
type: 'form'
|
||||
});
|
||||
if (response?.status === 200) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFavorites(): Promise<string[]> {
|
||||
const response = await getRequest('users/favorites');
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFavorite(icao: string): Promise<boolean> {
|
||||
const response = await postRequest(`users/favorites/${icao}`);
|
||||
if (response?.status === 200) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFavorite(icao: string): Promise<boolean> {
|
||||
const response = await deleteRequest(`users/favorites/${icao}`);
|
||||
if (response?.status === 200) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createAirport, removeAirport, updateAirport } from "@/api/airport";
|
||||
import { Airport } from "@/api/airport.types";
|
||||
import AirportForm from "@/components/Admin/AirportForm";
|
||||
import AirportTablePanel from "@/components/Admin/AirportTablePanel";
|
||||
import { isAdminState } from "@/state/auth";
|
||||
import { Container, Grid, Modal, SimpleGrid } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { useRecoilValue } from "recoil";
|
||||
|
||||
export default function Page() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [airport, setAirport] = useState<Airport | undefined>(undefined);
|
||||
const isAdmin = useRecoilValue(isAdminState);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAdmin && (
|
||||
<Container fluid>
|
||||
<SimpleGrid cols={{ base: 1, xs: 1 }} spacing={'md'}>
|
||||
<Grid p={'lg'}>
|
||||
<Grid.Col span={12}>
|
||||
<AirportTablePanel setShowModal={setShowModal} setAirport={setAirport} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SimpleGrid>
|
||||
<Modal size={'xl'} opened={showModal} onClose={() => {
|
||||
setAirport(undefined);
|
||||
setShowModal(false);
|
||||
}}>
|
||||
<AirportForm
|
||||
title={airport ? 'Update Airport' : 'Create Airport'}
|
||||
submitText={airport ? 'Update' : 'Create'}
|
||||
airport={airport}
|
||||
onDelete={airport ? async () => {
|
||||
const response = await removeAirport({ icao: airport.icao });
|
||||
setShowModal(false);
|
||||
} : undefined}
|
||||
onSubmit={async (value) => {
|
||||
if (airport) {
|
||||
const response = await updateAirport({ airport: value });
|
||||
} else {
|
||||
const response = await createAirport({ airport: value });
|
||||
}
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</Container>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getAirport } from '@/api/airport';
|
||||
import { Airport } from '@/api/airport.types';
|
||||
import { getMetars } from '@/api/metar';
|
||||
import { Metar } from '@/api/metar.types';
|
||||
import { Grid, Title, Text } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Page({ params }: { params: { icao: string } }) {
|
||||
const [airport, setAirport] = useState<Airport | undefined>(undefined);
|
||||
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAirport() {
|
||||
const airportData = await getAirport({ icao: params.icao });
|
||||
setAirport(airportData);
|
||||
const metarData = await getMetars([airportData.icao]);
|
||||
if (metarData.length > 0) {
|
||||
setMetar(metarData[0]);
|
||||
}
|
||||
}
|
||||
loadAirport();
|
||||
}, []);
|
||||
|
||||
if (airport) {
|
||||
return (
|
||||
<Grid gutter={80} style={{ margin: '0 0.5em'}}>
|
||||
<Grid.Col span={12}>
|
||||
<Title className='title' order={1}>{airport.icao} - {airport.name}</Title>
|
||||
<Text c="dimmed">
|
||||
{airport.municipality} | {airport.iso_region} | {airport.iso_country}
|
||||
</Text>
|
||||
{metar && (
|
||||
<Text c="dimmed">
|
||||
{metar.raw_text}
|
||||
</Text>
|
||||
)}
|
||||
<h3>Frequencies</h3>
|
||||
{airport.frequencies.map((frequency) => (
|
||||
<div key={frequency.frequency_mhz}>
|
||||
<ul>
|
||||
<li>{frequency.id}: {frequency.frequency_mhz} MHz</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<h3>Runway Information</h3>
|
||||
{airport.runways.map((runway) => (
|
||||
<div key={runway.id}>
|
||||
<b>Runway {runway.id}</b>
|
||||
<ul>
|
||||
<li>Dimensions: {runway.length_ft} x {runway.width_ft} ft.</li>
|
||||
<li>Surface: {runway.surface}</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import RecoilRootWrapper from '@app/recoil-root-wrapper';
|
||||
import { MantineProvider, Skeleton } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import 'styles/globals.css';
|
||||
import 'styles/leaflet.css';
|
||||
import '@mantine/core/styles.css';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import Loader from '@/components/Loader';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Aviation Weather',
|
||||
description: ''
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>Aviation Weather</title>
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider>
|
||||
<Notifications />
|
||||
<ModalsProvider>
|
||||
<RecoilRootWrapper>
|
||||
<React.Suspense fallback={<Skeleton/>}>
|
||||
<Loader>
|
||||
{children}
|
||||
</Loader>
|
||||
</React.Suspense>
|
||||
</RecoilRootWrapper>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
import Metar from '@/components/Metars';
|
||||
|
||||
export default function Page() {
|
||||
return <Metar />;
|
||||
// return <></>;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getAirports } from "@/api/airport";
|
||||
import { Airport } from "@/api/airport.types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRecoilState, useRecoilValue } from "recoil";
|
||||
import { Autocomplete, Badge, Box, Button, Card, Grid, Group, SimpleGrid, Text, Title } from "@mantine/core";
|
||||
import classes from './profile.module.css';
|
||||
import { addFavorite, getFavorites, removeFavorite } from "@/api/users";
|
||||
import { getMetars } from "@/api/metar";
|
||||
import { Metar } from "@/api/metar.types";
|
||||
import { MdLocationSearching } from 'react-icons/md';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { coordinatesState } from "@/state/map";
|
||||
import { userState } from "@/state/auth";
|
||||
|
||||
export default function Page() {
|
||||
const user = useRecoilValue(userState);
|
||||
|
||||
return (
|
||||
<Grid gutter={80}>
|
||||
<Grid.Col span={12}>
|
||||
<Box m="lg">
|
||||
<Title className={classes.title} order={2}>
|
||||
{user?.first_name} {user?.last_name}
|
||||
</Title>
|
||||
<hr />
|
||||
<Text c="dimmed">
|
||||
|
||||
</Text>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<TopSection />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSection() {
|
||||
const [airports, setAirports] = useState<Airport[]>([]);
|
||||
const [metars, setMetars] = useState<Metar[]>([]);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [searchAirports, setSearchAirports] = useState<Airport[]>([]);
|
||||
const router = useRouter();
|
||||
const [_, setCoordinates] = useRecoilState(coordinatesState);
|
||||
|
||||
useEffect(() => {
|
||||
updateFavorites();
|
||||
}, []);
|
||||
|
||||
function metarColor(metar?: Metar): string {
|
||||
switch (metar?.flight_category) {
|
||||
case 'VFR':
|
||||
return 'green';
|
||||
case 'MVFR':
|
||||
return 'blue';
|
||||
case 'IFR':
|
||||
return 'red';
|
||||
case 'LIFR':
|
||||
return 'purple';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
function AirportCard(airport: Airport) {
|
||||
let metar = metars.find((m) => m.station_id === airport.icao);
|
||||
let color = metarColor(metar);
|
||||
let text = metar?.flight_category || 'UNKN';
|
||||
|
||||
return (
|
||||
<Card key={airport.icao} shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" mt="md" mb="xs">
|
||||
<Text fw={500} style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', width: '20em' }}>{airport.name}</Text>
|
||||
<Badge color={color} variant="light">{text}</Badge>
|
||||
</Group>
|
||||
<Group style={{ cursor: 'pointer', userSelect: 'none' }} onClick={() => {
|
||||
setCoordinates({
|
||||
lat: airport.latitude,
|
||||
lon: airport.longitude,
|
||||
});
|
||||
router.push('/');
|
||||
}}>
|
||||
<MdLocationSearching size={20} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{airport.latitude.toFixed(3)}, {airport.longitude.toFixed(3)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
size="sm"
|
||||
radius="lg"
|
||||
style={{ marginTop: '10px' }}
|
||||
onClick={() => {
|
||||
router.push(`/airport/${airport.icao}`);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
size="sm"
|
||||
radius="lg"
|
||||
style={{ marginTop: '10px' }}
|
||||
onClick={async () => {
|
||||
await removeFavorite(airport.icao);
|
||||
await updateFavorites();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function updateFavorites() {
|
||||
const favorites = await getFavorites();
|
||||
const m = (await getMetars(favorites)).data;
|
||||
setMetars(m);
|
||||
const a = (await getAirports({ icaos: favorites })).data;
|
||||
setAirports(a);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<Grid gutter={80}>
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Title className={classes.title} order={2}>
|
||||
Logbook
|
||||
</Title>
|
||||
<hr />
|
||||
<Text c="dimmed">
|
||||
Your logbook is a list of your flights. You can add flights to your logbook by clicking the "Add to logbook" button on the flight page.
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Title className={classes.title} order={2}>
|
||||
Saved Airports
|
||||
</Title>
|
||||
<hr />
|
||||
<Autocomplete
|
||||
label='Add an airport to your favorites'
|
||||
placeholder='ICAO or Airport Name'
|
||||
value={search}
|
||||
data={searchAirports.map((a) => ({ value: a.icao, label: `${a.icao} - ${a.name}` }))}
|
||||
limit={5}
|
||||
style={{ paddingBottom: '10px' }}
|
||||
onChange={async (value) => {
|
||||
setSearch(value);
|
||||
if (value) {
|
||||
const a = await getAirports({ icaos: [value], name: value });
|
||||
setSearchAirports(a.data);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={async (value) => {
|
||||
if (!airports.find((a) => a.icao === value)) {
|
||||
await addFavorite(value);
|
||||
await updateFavorites();
|
||||
}
|
||||
setSearch('');
|
||||
}}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={30}>
|
||||
{airports.map((airport) => AirportCard(airport))}
|
||||
</SimpleGrid>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.wrapper {
|
||||
padding: calc(var(--mantine-spacing-xl) * 2) var(--mantine-spacing-xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family:
|
||||
Greycliff CF,
|
||||
var(--mantine-font-family);
|
||||
font-size: rem(36px);
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
export default function RecoilRootWrapper({ children }: { children: ReactNode }) {
|
||||
return <RecoilRoot>{children}</RecoilRoot>;
|
||||
}
|
||||
1
ui/src/assets/react.svg
Normal file
1
ui/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
33
ui/src/components/Header/Header.module.css
Normal file
33
ui/src/components/Header/Header.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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='© <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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}°
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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 <></>;
|
||||
}
|
||||
};
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.sidebar {
|
||||
width: 62px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.option-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import './Sidebar.css';
|
||||
|
||||
export default function Sidebar() {
|
||||
return <div className=''></div>;
|
||||
}
|
||||
0
ui/src/index.css
Normal file
0
ui/src/index.css
Normal file
@@ -1,5 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme } from '@mantine/core';
|
||||
|
||||
export const theme = createTheme({});
|
||||
25
ui/src/main.tsx
Normal file
25
ui/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { createTheme, MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import {} from '@mantine/core';
|
||||
|
||||
const theme = createTheme({
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: 'Aviation Weather',
|
||||
description: ''
|
||||
};
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme={'dark'}>
|
||||
<Notifications />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
import { User } from '@/api/auth.types';
|
||||
import { atom, selector } from 'recoil';
|
||||
|
||||
export const userState = atom({
|
||||
key: 'userState',
|
||||
default: undefined as User | undefined
|
||||
});
|
||||
|
||||
export const isAdminState = selector({
|
||||
key: 'isAdminState',
|
||||
get: ({ get }) => {
|
||||
const user = get(userState);
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
});
|
||||
|
||||
export const refreshIdState = atom({
|
||||
key: 'refreshIdState',
|
||||
default: undefined as NodeJS.Timeout | undefined
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Coordinate } from '@/api/airport.types';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const coordinatesState = atom({
|
||||
key: 'coordinatesState',
|
||||
default: { lat: 38.7209, lon: -77.5133 } as Coordinate
|
||||
});
|
||||
|
||||
export const zoomState = atom({
|
||||
key: 'zoomState',
|
||||
default: 8
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const favoritesState = atom({
|
||||
key: 'favoritesState',
|
||||
default: [] as string[]
|
||||
});
|
||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user