Cleanup old code
1
.gitignore
vendored
@@ -1,7 +1,6 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
venv/
|
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Aviation Weather
|
# Aviation
|
||||||
|
|
||||||
## Makefile
|
## Makefile
|
||||||
`make help` to list all commands
|
`make help` to list all commands
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# =========
|
# =========
|
||||||
# Builder
|
# Builder
|
||||||
# =========
|
# =========
|
||||||
FROM rust:bookworm as builder
|
FROM rust:bookworm AS builder
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
@@ -14,7 +14,7 @@ RUN cargo build --release
|
|||||||
# ======
|
# ======
|
||||||
# Keys
|
# Keys
|
||||||
# ======
|
# ======
|
||||||
FROM debian:bookworm-slim as keys
|
FROM debian:bookworm-slim AS keys
|
||||||
WORKDIR /keys
|
WORKDIR /keys
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y openssl libpq-dev
|
RUN apt-get update && apt-get install -y openssl libpq-dev
|
||||||
@@ -26,7 +26,7 @@ RUN openssl rsa -in refresh.pem -pubout -outform PEM -out refresh.pem.pub
|
|||||||
# =========
|
# =========
|
||||||
# Runtime
|
# Runtime
|
||||||
# =========
|
# =========
|
||||||
FROM keys as runtime
|
FROM keys AS runtime
|
||||||
WORKDIR /api
|
WORKDIR /api
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ services:
|
|||||||
- "${API_PORT:-5000}:5000"
|
- "${API_PORT:-5000}:5000"
|
||||||
build:
|
build:
|
||||||
context: api
|
context: api
|
||||||
|
dockerfile: Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- redis
|
- redis
|
||||||
@@ -88,7 +89,7 @@ services:
|
|||||||
- ${UI_PORT:-3000}:3000
|
- ${UI_PORT:-3000}:3000
|
||||||
build:
|
build:
|
||||||
context: ./ui/
|
context: ./ui/
|
||||||
target: dev
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ./ui/src:/app/src
|
- ./ui/src:/app/src
|
||||||
- ./ui/public:/app/public
|
- ./ui/public:/app/public
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"root": true,
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint/eslint-plugin"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:prettier/recommended"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/interface-name-prefix": "off",
|
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
18.17.1
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"trailingComma": "none",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"jsxSingleQuote": true,
|
|
||||||
"printWidth": 120
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Base
|
|
||||||
FROM node:21-alpine AS base
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
FROM base as deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Dev
|
|
||||||
FROM base AS dev
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Builder
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Runner
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
|
||||||
COPY --from=builder /app/next.config.js ./
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
EXPOSE 3000
|
|
||||||
ENV PORT 3000
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!make
|
|
||||||
SHELL := /bin/bash
|
|
||||||
|
|
||||||
GIT_HASH ?= $(shell git log --format="%h" -n 1)
|
|
||||||
|
|
||||||
include .env
|
|
||||||
-include .env.local
|
|
||||||
export
|
|
||||||
|
|
||||||
.PHONY: help build start stop lint
|
|
||||||
|
|
||||||
help: ## This info
|
|
||||||
@echo
|
|
||||||
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
|
||||||
@echo
|
|
||||||
|
|
||||||
build: ## Install the dependencies and build
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
tag: ## Tag the Docker image
|
|
||||||
docker tag aviation-ui:latest aviation-ui:${GIT_HASH}
|
|
||||||
|
|
||||||
up: ## Start the dev instance
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
down: ## Stop the dev instance
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
lint: ## Run the linter
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
clean: ## Remove node modules
|
|
||||||
docker compose down && \
|
|
||||||
docker image rm aviation-ui
|
|
||||||
5
ui-old/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
swcMinify: true,
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true
|
|
||||||
},
|
|
||||||
publicRuntimeConfig: {
|
|
||||||
// remove private variables from processEnv
|
|
||||||
processEnv: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.includes('NEXT_PUBLIC_')))
|
|
||||||
},
|
|
||||||
output: 'standalone',
|
|
||||||
experimental: {
|
|
||||||
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
||||||
5445
ui-old/package-lock.json
generated
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "aviation-weather",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mantine/core": "^7.3.2",
|
|
||||||
"@mantine/form": "^7.3.2",
|
|
||||||
"@mantine/hooks": "^7.3.2",
|
|
||||||
"@mantine/modals": "^7.3.2",
|
|
||||||
"@mantine/notifications": "^7.3.2",
|
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"next": "^14.0.4",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-icons": "^4.11.0",
|
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"recharts": "^2.10.3",
|
|
||||||
"recoil": "^0.7.7"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/leaflet": "^1.9.8",
|
|
||||||
"@types/node": "20.10.5",
|
|
||||||
"@types/react": "18.2.45",
|
|
||||||
"@types/react-dom": "18.2.18",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
|
||||||
"@typescript-eslint/parser": "^6.15.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"eslint": "8.56.0",
|
|
||||||
"eslint-config-next": "14.0.4",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-prettier": "^5.1.0",
|
|
||||||
"postcss": "^8.4.32",
|
|
||||||
"postcss-import": "^15.1.0",
|
|
||||||
"postcss-preset-mantine": "^1.12.1",
|
|
||||||
"postcss-simple-vars": "^7.0.1",
|
|
||||||
"prettier": "^3.1.1",
|
|
||||||
"typescript": "5.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
'postcss-preset-mantine': {},
|
|
||||||
'postcss-simple-vars': {
|
|
||||||
variables: {
|
|
||||||
'mantine-breakpoint-xs': '36em',
|
|
||||||
'mantine-breakpoint-sm': '48em',
|
|
||||||
'mantine-breakpoint-md': '62em',
|
|
||||||
'mantine-breakpoint-lg': '75em',
|
|
||||||
'mantine-breakpoint-xl': '88em',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'postcss-import': {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
|
|
||||||
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(255,0,0);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(130.653,0,0,130.653,45.627,110.762)">
|
|
||||||
<g transform="matrix(1,0,0,1,0.277832,0)">
|
|
||||||
</g>
|
|
||||||
<text x="0px" y="0px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:1px;fill:white;">I</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 871 B |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
|
|
||||||
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(128,0,128);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(128.435,0,0,128.435,25.8707,109.968)">
|
|
||||||
<g transform="matrix(1,0,0,1,0.556152,0)">
|
|
||||||
</g>
|
|
||||||
<text x="0px" y="0px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:1px;fill:white;">L</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 874 B |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
|
|
||||||
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(0,0,255);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(238.636,0,0,238.636,-4483.2,-6772.13)">
|
|
||||||
<g transform="matrix(0.50957,0,0,0.50957,19.2676,28.829)">
|
|
||||||
</g>
|
|
||||||
<text x="18.843px" y="28.829px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:0.51px;fill:white;">M</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 902 B |
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(1.99926,0,0,1.97614,-28.3993,-25.2946)">
|
|
||||||
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(105,105,105);"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 624 B |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(2.5585,0,0,2.52891,-36.3432,-32.3701)">
|
|
||||||
<ellipse cx="39.22" cy="38.107" rx="25.015" ry="25.307" style="fill:rgb(62,62,62);"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(238.636,0,0,238.636,-4476.43,-6772.87)">
|
|
||||||
<g transform="matrix(0.50957,0,0,0.50957,19.2111,28.829)">
|
|
||||||
</g>
|
|
||||||
<text x="18.843px" y="28.829px" style="font-family:'ArialMT', 'Arial', sans-serif;font-size:0.51px;fill:white;">U</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 904 B |
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="svg874" width="185.96mm" height="185.96mm" version="1.1" viewBox="0 0 185.96 185.96" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle id="path1419" cx="92.99" cy="92.983" r="92.982" fill="#28a745"/>
|
|
||||||
<g id="text1423" fill="#fff" stroke-width="13.795" style="font-feature-settings:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-variant-numeric:normal" aria-label="V">
|
|
||||||
<path id="path821" d="m120.94 45.222h17.819l-36.787 105.05h-18.106l-36.644-105.05h17.675l21.914 65.312q1.7244 4.6703 3.6644 12.071 1.94 7.3288 2.5148 10.921 0.93406-5.4606 2.874-12.646 1.94-7.1851 3.1614-10.634z" fill="#fff" stroke-width="13.795"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 695 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 618 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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,36 +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,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
'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';
|
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createTheme } from '@mantine/core';
|
|
||||||
|
|
||||||
export const theme = createTheme({});
|
|
||||||
@@ -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,44 +0,0 @@
|
|||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
|
||||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metar-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
padding: 6px;
|
|
||||||
background-color: #000;
|
|
||||||
border: 1px solid #fff;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #222;
|
|
||||||
white-space: nowrap;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper > nav {
|
|
||||||
flex: 0 0 56px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
@@ -1,623 +0,0 @@
|
|||||||
/* required styles */
|
|
||||||
|
|
||||||
.leaflet-pane,
|
|
||||||
.leaflet-tile,
|
|
||||||
.leaflet-marker-icon,
|
|
||||||
.leaflet-marker-shadow,
|
|
||||||
.leaflet-tile-container,
|
|
||||||
.leaflet-map-pane svg,
|
|
||||||
.leaflet-map-pane canvas,
|
|
||||||
.leaflet-zoom-box,
|
|
||||||
.leaflet-image-layer,
|
|
||||||
.leaflet-layer {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
.leaflet-container {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.leaflet-tile,
|
|
||||||
.leaflet-marker-icon,
|
|
||||||
.leaflet-marker-shadow {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
|
||||||
.leaflet-safari .leaflet-tile {
|
|
||||||
image-rendering: -webkit-optimize-contrast;
|
|
||||||
}
|
|
||||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
|
||||||
.leaflet-safari .leaflet-tile-container {
|
|
||||||
width: 1600px;
|
|
||||||
height: 1600px;
|
|
||||||
-webkit-transform-origin: 0 0;
|
|
||||||
}
|
|
||||||
.leaflet-marker-icon,
|
|
||||||
.leaflet-marker-shadow {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
|
||||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
|
||||||
.leaflet-container .leaflet-overlay-pane svg,
|
|
||||||
.leaflet-container .leaflet-marker-pane img,
|
|
||||||
.leaflet-container .leaflet-tile-pane img,
|
|
||||||
.leaflet-container img.leaflet-image-layer {
|
|
||||||
max-width: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-container.leaflet-touch-zoom {
|
|
||||||
-ms-touch-action: pan-x pan-y;
|
|
||||||
touch-action: pan-x pan-y;
|
|
||||||
}
|
|
||||||
.leaflet-container.leaflet-touch-drag {
|
|
||||||
-ms-touch-action: pinch-zoom;
|
|
||||||
}
|
|
||||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-drag {
|
|
||||||
-ms-touch-action: none;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
.leaflet-tile {
|
|
||||||
filter: inherit;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
.leaflet-tile-loaded {
|
|
||||||
visibility: inherit;
|
|
||||||
}
|
|
||||||
.leaflet-zoom-box {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
z-index: 800;
|
|
||||||
}
|
|
||||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
|
||||||
.leaflet-overlay-pane svg {
|
|
||||||
-moz-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-pane { z-index: 400; }
|
|
||||||
|
|
||||||
.leaflet-tile-pane { z-index: 200; }
|
|
||||||
.leaflet-overlay-pane { z-index: 400; }
|
|
||||||
.leaflet-shadow-pane { z-index: 500; }
|
|
||||||
.leaflet-marker-pane { z-index: 600; }
|
|
||||||
.leaflet-tooltip-pane { z-index: 650; }
|
|
||||||
.leaflet-popup-pane { z-index: 700; }
|
|
||||||
|
|
||||||
.leaflet-map-pane canvas { z-index: 100; }
|
|
||||||
.leaflet-map-pane svg { z-index: 200; }
|
|
||||||
|
|
||||||
.leaflet-vml-shape {
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
}
|
|
||||||
.lvml {
|
|
||||||
behavior: url(#default#VML);
|
|
||||||
display: inline-block;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* control positioning */
|
|
||||||
|
|
||||||
.leaflet-control {
|
|
||||||
position: relative;
|
|
||||||
z-index: 800;
|
|
||||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.leaflet-top,
|
|
||||||
.leaflet-bottom {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.leaflet-top {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
.leaflet-right {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
.leaflet-bottom {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
.leaflet-left {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
.leaflet-control {
|
|
||||||
float: left;
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
.leaflet-right .leaflet-control {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
.leaflet-top .leaflet-control {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.leaflet-bottom .leaflet-control {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.leaflet-left .leaflet-control {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
.leaflet-right .leaflet-control {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* zoom and fade animations */
|
|
||||||
|
|
||||||
.leaflet-fade-anim .leaflet-tile {
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
|
||||||
.leaflet-fade-anim .leaflet-popup {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transition: opacity 0.2s linear;
|
|
||||||
-moz-transition: opacity 0.2s linear;
|
|
||||||
-o-transition: opacity 0.2s linear;
|
|
||||||
transition: opacity 0.2s linear;
|
|
||||||
}
|
|
||||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.leaflet-zoom-animated {
|
|
||||||
-webkit-transform-origin: 0 0;
|
|
||||||
-ms-transform-origin: 0 0;
|
|
||||||
transform-origin: 0 0;
|
|
||||||
}
|
|
||||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
|
||||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
|
||||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
|
||||||
-o-transition: -o-transform 0.25s cubic-bezier(0,0,0.25,1);
|
|
||||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
|
||||||
}
|
|
||||||
.leaflet-zoom-anim .leaflet-tile,
|
|
||||||
.leaflet-pan-anim .leaflet-tile {
|
|
||||||
-webkit-transition: none;
|
|
||||||
-moz-transition: none;
|
|
||||||
-o-transition: none;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* cursors */
|
|
||||||
|
|
||||||
.leaflet-interactive {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.leaflet-grab {
|
|
||||||
cursor: -webkit-grab;
|
|
||||||
cursor: -moz-grab;
|
|
||||||
}
|
|
||||||
.leaflet-crosshair,
|
|
||||||
.leaflet-crosshair .leaflet-interactive {
|
|
||||||
cursor: crosshair;
|
|
||||||
}
|
|
||||||
.leaflet-popup-pane,
|
|
||||||
.leaflet-control {
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
||||||
.leaflet-dragging .leaflet-grab,
|
|
||||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
|
||||||
.leaflet-dragging .leaflet-marker-draggable {
|
|
||||||
cursor: move;
|
|
||||||
cursor: -webkit-grabbing;
|
|
||||||
cursor: -moz-grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* marker & overlays interactivity */
|
|
||||||
.leaflet-marker-icon,
|
|
||||||
.leaflet-marker-shadow,
|
|
||||||
.leaflet-image-layer,
|
|
||||||
.leaflet-pane > svg path,
|
|
||||||
.leaflet-tile-container {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-marker-icon.leaflet-interactive,
|
|
||||||
.leaflet-image-layer.leaflet-interactive,
|
|
||||||
.leaflet-pane > svg path.leaflet-interactive {
|
|
||||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* visual tweaks */
|
|
||||||
|
|
||||||
.leaflet-container {
|
|
||||||
background: #ddd;
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
.leaflet-container a {
|
|
||||||
color: #0078A8;
|
|
||||||
}
|
|
||||||
.leaflet-container a.leaflet-active {
|
|
||||||
outline: 2px solid orange;
|
|
||||||
}
|
|
||||||
.leaflet-zoom-box {
|
|
||||||
border: 2px dotted #38f;
|
|
||||||
background: rgba(255,255,255,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* general typography */
|
|
||||||
.leaflet-container {
|
|
||||||
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* general toolbar styles */
|
|
||||||
|
|
||||||
.leaflet-bar {
|
|
||||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.leaflet-bar a,
|
|
||||||
.leaflet-bar a:hover {
|
|
||||||
background-color: #fff;
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
line-height: 26px;
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
.leaflet-bar a,
|
|
||||||
.leaflet-control-layers-toggle {
|
|
||||||
background-position: 50% 50%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.leaflet-bar a:hover {
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
}
|
|
||||||
.leaflet-bar a:first-child {
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
}
|
|
||||||
.leaflet-bar a:last-child {
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.leaflet-bar a.leaflet-disabled {
|
|
||||||
cursor: default;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-touch .leaflet-bar a {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* zoom control */
|
|
||||||
|
|
||||||
.leaflet-control-zoom-in,
|
|
||||||
.leaflet-control-zoom-out {
|
|
||||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
|
||||||
text-indent: 1px;
|
|
||||||
}
|
|
||||||
.leaflet-control-zoom-out {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-touch .leaflet-control-zoom-in {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.leaflet-touch .leaflet-control-zoom-out {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* layers control */
|
|
||||||
|
|
||||||
.leaflet-control-layers {
|
|
||||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers-toggle {
|
|
||||||
background-image: url(../public/layers.png);
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
.leaflet-retina .leaflet-control-layers-toggle {
|
|
||||||
background-image: url(../public/layers-2x.png);
|
|
||||||
background-size: 26px 26px;
|
|
||||||
}
|
|
||||||
.leaflet-touch .leaflet-control-layers-toggle {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers .leaflet-control-layers-list,
|
|
||||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers-expanded {
|
|
||||||
padding: 6px 10px 6px 6px;
|
|
||||||
color: #333;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers-scrollbar {
|
|
||||||
overflow-y: scroll;
|
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers-selector {
|
|
||||||
margin-top: 2px;
|
|
||||||
position: relative;
|
|
||||||
top: 1px;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.leaflet-control-layers-separator {
|
|
||||||
height: 0;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
margin: 5px -10px 5px -6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Default icon URLs */
|
|
||||||
.leaflet-default-icon-path {
|
|
||||||
background-image: url(../public/marker-icon.png);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* attribution and scale controls */
|
|
||||||
|
|
||||||
.leaflet-container .leaflet-control-attribution {
|
|
||||||
background: #fff;
|
|
||||||
background: rgba(255, 255, 255, 0.7);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.leaflet-control-attribution,
|
|
||||||
.leaflet-control-scale-line {
|
|
||||||
padding: 0 5px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.leaflet-control-attribution a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.leaflet-control-attribution a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.leaflet-container .leaflet-control-attribution,
|
|
||||||
.leaflet-container .leaflet-control-scale {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.leaflet-left .leaflet-control-scale {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
.leaflet-bottom .leaflet-control-scale {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.leaflet-control-scale-line {
|
|
||||||
border: 2px solid #777;
|
|
||||||
border-top: none;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 2px 5px 1px;
|
|
||||||
font-size: 11px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
background: #fff;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
.leaflet-control-scale-line:not(:first-child) {
|
|
||||||
border-top: 2px solid #777;
|
|
||||||
border-bottom: none;
|
|
||||||
margin-top: -2px;
|
|
||||||
}
|
|
||||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
|
||||||
border-bottom: 2px solid #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-touch .leaflet-control-attribution,
|
|
||||||
.leaflet-touch .leaflet-control-layers,
|
|
||||||
.leaflet-touch .leaflet-bar {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.leaflet-touch .leaflet-control-layers,
|
|
||||||
.leaflet-touch .leaflet-bar {
|
|
||||||
border: 2px solid rgba(0,0,0,0.2);
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* popup */
|
|
||||||
|
|
||||||
.leaflet-popup {
|
|
||||||
position: absolute;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.leaflet-popup-content-wrapper {
|
|
||||||
padding: 1px;
|
|
||||||
text-align: left;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.leaflet-popup-content {
|
|
||||||
margin: 13px 19px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.leaflet-popup-content p {
|
|
||||||
margin: 18px 0;
|
|
||||||
}
|
|
||||||
.leaflet-popup-tip-container {
|
|
||||||
width: 40px;
|
|
||||||
height: 20px;
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
margin-left: -20px;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.leaflet-popup-tip {
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
padding: 1px;
|
|
||||||
|
|
||||||
margin: -10px auto 0;
|
|
||||||
|
|
||||||
-webkit-transform: rotate(45deg);
|
|
||||||
-moz-transform: rotate(45deg);
|
|
||||||
-ms-transform: rotate(45deg);
|
|
||||||
-o-transform: rotate(45deg);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
.leaflet-popup-content-wrapper,
|
|
||||||
.leaflet-popup-tip {
|
|
||||||
background: white;
|
|
||||||
color: #333;
|
|
||||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
.leaflet-container a.leaflet-popup-close-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 4px 4px 0 0;
|
|
||||||
border: none;
|
|
||||||
text-align: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 14px;
|
|
||||||
font: 16px/14px Tahoma, Verdana, sans-serif;
|
|
||||||
color: #c3c3c3;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.leaflet-container a.leaflet-popup-close-button:hover {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
.leaflet-popup-scrolled {
|
|
||||||
overflow: auto;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
|
||||||
zoom: 1;
|
|
||||||
}
|
|
||||||
.leaflet-oldie .leaflet-popup-tip {
|
|
||||||
width: 24px;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
|
||||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
|
||||||
}
|
|
||||||
.leaflet-oldie .leaflet-popup-tip-container {
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-oldie .leaflet-control-zoom,
|
|
||||||
.leaflet-oldie .leaflet-control-layers,
|
|
||||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
|
||||||
.leaflet-oldie .leaflet-popup-tip {
|
|
||||||
border: 1px solid #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* div icon */
|
|
||||||
|
|
||||||
.leaflet-div-icon {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Tooltip */
|
|
||||||
/* Base styles for the element that has a tooltip */
|
|
||||||
.leaflet-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
padding: 6px;
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #fff;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #222;
|
|
||||||
white-space: nowrap;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
.leaflet-tooltip.leaflet-clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-top:before,
|
|
||||||
.leaflet-tooltip-bottom:before,
|
|
||||||
.leaflet-tooltip-left:before,
|
|
||||||
.leaflet-tooltip-right:before {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
border: 6px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Directions */
|
|
||||||
|
|
||||||
.leaflet-tooltip-bottom {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-top {
|
|
||||||
margin-top: -6px;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-bottom:before,
|
|
||||||
.leaflet-tooltip-top:before {
|
|
||||||
left: 50%;
|
|
||||||
margin-left: -6px;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-top:before {
|
|
||||||
bottom: 0;
|
|
||||||
margin-bottom: -12px;
|
|
||||||
border-top-color: #fff;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-bottom:before {
|
|
||||||
top: 0;
|
|
||||||
margin-top: -12px;
|
|
||||||
margin-left: -6px;
|
|
||||||
border-bottom-color: #fff;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-left {
|
|
||||||
margin-left: -6px;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-right {
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-left:before,
|
|
||||||
.leaflet-tooltip-right:before {
|
|
||||||
top: 50%;
|
|
||||||
margin-top: -6px;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-left:before {
|
|
||||||
right: 0;
|
|
||||||
margin-right: -12px;
|
|
||||||
border-left-color: #fff;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-right:before {
|
|
||||||
left: 0;
|
|
||||||
margin-left: -12px;
|
|
||||||
border-right-color: #fff;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"downlevelIteration": true,
|
|
||||||
"lib": [
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"],
|
|
||||||
"@api/*": ["src/api"],
|
|
||||||
"@app/*": ["./src/app/*"],
|
|
||||||
"@components/*": ["src/components/*"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ WORKDIR /builder
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN \
|
RUN \
|
||||||
if [ -f package.json ]; then npm i && npm run build; \
|
if [ -f package.json ]; then npm i && npm run build; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "package.json not found." && exit 2; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { MapContainer, TileLayer } from 'react-leaflet';
|
import { MapContainer, TileLayer, ZoomControl } from 'react-leaflet';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
// Fix for default marker icon issues in React-Leaflet
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { Header } from '@components/Header';
|
import { Header } from '@components/Header';
|
||||||
import AirportLayer from '@components/AirportLayer.tsx';
|
import AirportLayer from '@components/AirportLayer.tsx';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Airport } from '@lib/airport.types.ts';
|
||||||
|
import AirportDrawer from '@components/AirportDrawer.tsx';
|
||||||
|
|
||||||
// Fix Leaflet's default icon path issues with Webpack
|
// Fix Leaflet's default icon path issues with Webpack
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -23,16 +24,21 @@ L.Icon.Default.mergeOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||||
|
const defaultZoom = 6;
|
||||||
|
const defaultCenter: L.LatLngExpression = [38.944444, -77.455833];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [airport, setAirport] = useState<Airport | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className='App'>
|
<div className='App'>
|
||||||
<Header />
|
<Header />
|
||||||
<div className='map-wrapper'>
|
<div className='map-wrapper'>
|
||||||
|
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||||
<MapContainer
|
<MapContainer
|
||||||
className='leaflet-container'
|
className='leaflet-container'
|
||||||
center={[38.944444, -77.455833]}
|
attributionControl={false}
|
||||||
zoom={6}
|
center={defaultCenter}
|
||||||
|
zoom={defaultZoom}
|
||||||
minZoom={3}
|
minZoom={3}
|
||||||
maxZoom={19}
|
maxZoom={19}
|
||||||
maxBounds={[
|
maxBounds={[
|
||||||
@@ -40,9 +46,11 @@ function App() {
|
|||||||
[85.06, 180]
|
[85.06, 180]
|
||||||
]}
|
]}
|
||||||
scrollWheelZoom={true}
|
scrollWheelZoom={true}
|
||||||
|
zoomControl={false}
|
||||||
>
|
>
|
||||||
|
<ZoomControl position={'bottomright'} />
|
||||||
<TileLayer url={tileLayerUrl} />
|
<TileLayer url={tileLayerUrl} />
|
||||||
<AirportLayer />
|
<AirportLayer setAirport={setAirport} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
ui/src/components/AirportDrawer.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Divider, Drawer, Group } from '@mantine/core';
|
||||||
|
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||||
|
|
||||||
|
export default function AirportDrawer({
|
||||||
|
airport,
|
||||||
|
setAirport
|
||||||
|
}: {
|
||||||
|
airport: Airport | null;
|
||||||
|
setAirport: (airport: Airport | null) => void;
|
||||||
|
}) {
|
||||||
|
if (!airport) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
opened={true}
|
||||||
|
onClose={() => setAirport(null)}
|
||||||
|
title={airport.name}
|
||||||
|
withinPortal
|
||||||
|
zIndex={10000}
|
||||||
|
styles={{ root: { width: 0, height: 0 } }}
|
||||||
|
padding='md'
|
||||||
|
size='md'
|
||||||
|
position='left'
|
||||||
|
withOverlay={false}
|
||||||
|
closeOnClickOutside={false}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<div>ICAO: {airport.icao}</div>
|
||||||
|
<div>Category: {airportCategoryToText(airport.category)}</div>
|
||||||
|
<div>
|
||||||
|
Country / Region: {airport.iso_country}, {airport.iso_region}
|
||||||
|
</div>
|
||||||
|
<div>Municipality: {airport.municipality || 'N/A'}</div>
|
||||||
|
<div>Local Code: {airport.local || 'N/A'}</div>
|
||||||
|
<div>Elevation: {airport.elevation_ft}</div>
|
||||||
|
<div>
|
||||||
|
Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)}
|
||||||
|
</div>
|
||||||
|
<div>Control Tower: {airport.has_tower ? 'Yes' : 'No'}</div>
|
||||||
|
<div>Beacon: {airport.has_beacon ? 'Yes' : 'No'}</div>
|
||||||
|
{airport.latest_metar && airport.latest_metar.flight_category && (
|
||||||
|
<>
|
||||||
|
<Divider my='sm' />
|
||||||
|
<div>Flight Category: {airport.latest_metar.flight_category}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'Balloon Port';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,104 +1,64 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||||
import { Marker, Popup, useMapEvents } from 'react-leaflet';
|
import { useMapEvents } from 'react-leaflet';
|
||||||
import { getAirports } from '@lib/airport.ts';
|
import { getAirports } from '@lib/airport.ts';
|
||||||
import L from 'leaflet';
|
import AirportMarker from '@components/AirportMarker.tsx';
|
||||||
|
import { LeafletEvent } from 'leaflet';
|
||||||
|
|
||||||
interface Bounds {
|
interface Bounds {
|
||||||
northEast: { lat: number; lon: number };
|
northEast: { lat: number; lon: number };
|
||||||
southWest: { lat: number; lon: number };
|
southWest: { lat: number; lon: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AirportLayer() {
|
export default function AirportLayer({ setAirport }: { setAirport: (airport: Airport) => void }) {
|
||||||
const [airports, setAirports] = useState<Airport[]>([]);
|
const [airports, setAirports] = useState<Airport[]>([]);
|
||||||
|
|
||||||
useMapEvents({
|
function loadAirports(event: LeafletEvent) {
|
||||||
moveend: (event) => {
|
const map = event.target;
|
||||||
const map = event.target;
|
const bounds = map.getBounds();
|
||||||
const bounds = map.getBounds();
|
|
||||||
|
|
||||||
const boundsParam: Bounds = {
|
const boundsParam: Bounds = {
|
||||||
northEast: {
|
northEast: {
|
||||||
lat: bounds.getNorth(),
|
lat: bounds.getNorth(),
|
||||||
lon: bounds.getEast()
|
lon: bounds.getEast()
|
||||||
},
|
},
|
||||||
southWest: {
|
southWest: {
|
||||||
lat: bounds.getSouth(),
|
lat: bounds.getSouth(),
|
||||||
lon: bounds.getWest()
|
lon: bounds.getWest()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call getAirports with the current map bounds and desired parameters.
|
getAirports({
|
||||||
getAirports({
|
bounds: boundsParam,
|
||||||
bounds: boundsParam,
|
metars: true,
|
||||||
metars: true,
|
categories: [AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE],
|
||||||
categories: [AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE],
|
limit: 200
|
||||||
limit: 200
|
})
|
||||||
|
.then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
setAirports(response.data);
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.catch((error) => {
|
||||||
console.log(response);
|
console.error('Error fetching airports:', error);
|
||||||
setAirports(response.data);
|
setAirports([]);
|
||||||
})
|
});
|
||||||
.catch((error) => {
|
}
|
||||||
console.error('Error fetching airports:', error);
|
|
||||||
setAirports([]);
|
const map = useMapEvents({
|
||||||
});
|
moveend: loadAirports
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (map) {
|
||||||
|
loadAirports({ target: map } as LeafletEvent);
|
||||||
|
}
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{airports.map((airport, index) => {
|
{airports.map((airport, index) => {
|
||||||
const markerColor = getMarkerColor(airport);
|
return <AirportMarker airport={airport} index={index} setAirport={setAirport} />;
|
||||||
const icon = createCustomIcon(markerColor);
|
|
||||||
return (
|
|
||||||
<Marker key={index} position={[airport.latitude, airport.longitude]} icon={icon}>
|
|
||||||
<Popup>
|
|
||||||
<div>
|
|
||||||
<h3>{airport.name || 'Unnamed Airport'}</h3>
|
|
||||||
<p>ICAO: {airport.icao || 'N/A'}</p>
|
|
||||||
<p>Flight Category: {airport.latest_metar ? airport.latest_metar.flight_category : 'No METAR Data'}</p>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMarkerColor(airport: Airport): string {
|
|
||||||
if (airport.latest_metar) {
|
|
||||||
switch (airport.latest_metar.flight_category.toUpperCase()) {
|
|
||||||
case 'IFR':
|
|
||||||
return '#ff0100';
|
|
||||||
case 'LIFR':
|
|
||||||
return '#7f007f';
|
|
||||||
case 'MVFR':
|
|
||||||
return '#00f';
|
|
||||||
case 'VFR':
|
|
||||||
return '#018000';
|
|
||||||
case 'UNKNOWN':
|
|
||||||
return '#3e3e3e';
|
|
||||||
default:
|
|
||||||
return '#3e3e3e';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return '#696969';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCustomIcon(color: string): L.DivIcon {
|
|
||||||
return L.divIcon({
|
|
||||||
html: `<div style="
|
|
||||||
background-color: ${color};
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
"></div>`,
|
|
||||||
className: '',
|
|
||||||
iconSize: [20, 20],
|
|
||||||
iconAnchor: [10, 10]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
62
ui/src/components/AirportMarker.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Airport } from '@lib/airport.types.ts';
|
||||||
|
import { Marker } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
export default function AirportMarker({
|
||||||
|
index,
|
||||||
|
airport,
|
||||||
|
setAirport
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
airport: Airport;
|
||||||
|
setAirport: (airport: Airport) => void;
|
||||||
|
}) {
|
||||||
|
const markerColor = getMarkerColor(airport);
|
||||||
|
const icon = createCustomIcon(markerColor);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={index}
|
||||||
|
position={[airport.latitude, airport.longitude]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => setAirport(airport)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkerColor(airport: Airport): string {
|
||||||
|
if (airport.latest_metar) {
|
||||||
|
switch (airport.latest_metar.flight_category.toUpperCase()) {
|
||||||
|
case 'IFR':
|
||||||
|
return '#ff0100';
|
||||||
|
case 'LIFR':
|
||||||
|
return '#7f007f';
|
||||||
|
case 'MVFR':
|
||||||
|
return '#00f';
|
||||||
|
case 'VFR':
|
||||||
|
return '#018000';
|
||||||
|
case 'UNKNOWN':
|
||||||
|
return '#3e3e3e';
|
||||||
|
default:
|
||||||
|
return '#3e3e3e';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return '#696969';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomIcon(color: string): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div style="
|
||||||
|
background-color: ${color};
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
"></div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10]
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
.header {
|
.header {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
|
padding: 0 16px 0 16px;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
}
|
}
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0 10px; /* Optional horizontal padding */
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@@ -20,13 +22,20 @@
|
|||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
font-size: var(--mantine-font-size-sm);
|
font-size: var(--mantine-font-size-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
@mixin hover {
|
}
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
|
||||||
}
|
.link: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);
|
[data-mantine-color-scheme] .link[data-active] {
|
||||||
}
|
background-color: var(--mantine-color-blue-filled);
|
||||||
|
color: var(--mantine-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center the navigation items */
|
||||||
|
.navGroup {
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Avatar, Burger, Container, Group, Text } from '@mantine/core';
|
import { Avatar, Box, Burger, Button, Container, Group, Text } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
// import { ReactComponent as Logo } from '../../../public/logo.svg';
|
|
||||||
import classes from './Header.module.css';
|
import classes from './Header.module.css';
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
@@ -13,8 +12,9 @@ const links = [
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
const [active, setActive] = useState(links[0].link);
|
const [active, setActive] = useState(links[0].link);
|
||||||
|
const isSignedIn = false;
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const navItems = links.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.label}
|
key={link.label}
|
||||||
href={link.link}
|
href={link.link}
|
||||||
@@ -30,19 +30,35 @@ export function Header() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={classes.header}>
|
<Box>
|
||||||
<Container size='md' className={classes.inner}>
|
<header className={classes.header}>
|
||||||
<span style={{ display: 'flex', flexDirection: 'row' }}>
|
<Group justify='space-between' h='100%'>
|
||||||
<Text>Aviation Weather</Text>
|
<Group align='center' gap='xs'>
|
||||||
<Avatar src='../../../public/logo.svg' alt="it's me" />
|
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
|
||||||
</span>
|
<Avatar src='/logo.svg' alt='logo' />
|
||||||
{/*<Logo />*/}
|
<Text>Aviation</Text>
|
||||||
<Group gap={5} visibleFrom='xs'>
|
</Group>
|
||||||
{items}
|
<Group gap={5} visibleFrom='xs' className={classes.navGroup}>
|
||||||
|
{navItems}
|
||||||
|
</Group>
|
||||||
|
<Group align='center' gap='xs'>
|
||||||
|
{isSignedIn ? (
|
||||||
|
// Clickable avatar if signed in
|
||||||
|
<Avatar
|
||||||
|
src='/user-avatar.jpg' // replace with dynamic source when available
|
||||||
|
alt='User avatar'
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
// Add click handler for user dropdown if needed
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant='default'>Login</Button>
|
||||||
|
<Button>Signup</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
</header>
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
|
</Box>
|
||||||
</Container>
|
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,37 +11,6 @@ export enum AirportCategory {
|
|||||||
UNKNOWN = 'unknown'
|
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 'Balloon Port';
|
|
||||||
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 {
|
export interface Bounds {
|
||||||
northEast: Coordinate;
|
northEast: Coordinate;
|
||||||
southWest: Coordinate;
|
southWest: Coordinate;
|
||||||
@@ -66,10 +35,9 @@ export interface Airport {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
has_tower: boolean;
|
has_tower: boolean;
|
||||||
has_beacon: boolean;
|
has_beacon: boolean;
|
||||||
has_metar: boolean;
|
|
||||||
public: boolean;
|
|
||||||
runways: Runway[];
|
runways: Runway[];
|
||||||
frequencies: Frequency[];
|
frequencies: Frequency[];
|
||||||
|
public: boolean;
|
||||||
latest_metar?: Metar;
|
latest_metar?: Metar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const theme = createTheme({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Aviation Weather',
|
title: 'Aviation',
|
||||||
description: ''
|
description: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||