Added airport data to map
This commit is contained in:
@@ -46,11 +46,12 @@ pub struct AirportQuery {
|
|||||||
pub icaos: Option<String>,
|
pub icaos: Option<String>,
|
||||||
pub iatas: Option<String>,
|
pub iatas: Option<String>,
|
||||||
pub locals: Option<String>,
|
pub locals: Option<String>,
|
||||||
pub names: Option<String>,
|
pub name: Option<String>,
|
||||||
pub categories: Option<String>,
|
pub categories: Option<String>,
|
||||||
pub iso_countries: Option<String>,
|
pub iso_countries: Option<String>,
|
||||||
pub iso_regions: Option<String>,
|
pub iso_regions: Option<String>,
|
||||||
pub municipalities: Option<String>,
|
pub municipalities: Option<String>,
|
||||||
|
pub bounds: Option<String>,
|
||||||
pub metars: Option<bool>,
|
pub metars: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,16 +63,48 @@ impl Default for AirportQuery {
|
|||||||
icaos: None,
|
icaos: None,
|
||||||
iatas: None,
|
iatas: None,
|
||||||
locals: None,
|
locals: None,
|
||||||
names: None,
|
name: None,
|
||||||
categories: None,
|
categories: None,
|
||||||
iso_countries: None,
|
iso_countries: None,
|
||||||
iso_regions: None,
|
iso_regions: None,
|
||||||
municipalities: None,
|
municipalities: None,
|
||||||
|
bounds: None,
|
||||||
metars: None,
|
metars: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Bounds {
|
||||||
|
pub north_east_lat: f32,
|
||||||
|
pub north_east_lon: f32,
|
||||||
|
pub south_west_lat: f32,
|
||||||
|
pub south_west_lon: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bounds {
|
||||||
|
fn parse(input: &str) -> ApiResult<Bounds> {
|
||||||
|
let parts: Vec<&str> = input.split(',').collect();
|
||||||
|
if parts.len() != 4 {
|
||||||
|
return Err(Error::new(
|
||||||
|
400,
|
||||||
|
format!("Expected 4 fields in bounds but received {}", parts.len()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let north_east_lat = parts[0].trim().parse::<f32>()?;
|
||||||
|
let north_east_lon = parts[1].trim().parse::<f32>()?;
|
||||||
|
let south_west_lat = parts[2].trim().parse::<f32>()?;
|
||||||
|
let south_west_lon = parts[3].trim().parse::<f32>()?;
|
||||||
|
|
||||||
|
Ok(Bounds {
|
||||||
|
north_east_lat,
|
||||||
|
north_east_lon,
|
||||||
|
south_west_lat,
|
||||||
|
south_west_lon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Deserialize, sqlx::FromRow)]
|
||||||
struct AirportRow {
|
struct AirportRow {
|
||||||
pub icao: String,
|
pub icao: String,
|
||||||
@@ -265,8 +298,20 @@ impl Airport {
|
|||||||
&query.municipalities,
|
&query.municipalities,
|
||||||
);
|
);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
|
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "name", &query.names);
|
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
||||||
|
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
|
||||||
|
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
|
||||||
|
|
||||||
|
// Order by AircraftCategory
|
||||||
|
builder.push(" ORDER BY CASE category ");
|
||||||
|
builder.push(" WHEN 'large_airport' THEN 1 ");
|
||||||
|
builder.push(" WHEN 'medium_airport' THEN 2 ");
|
||||||
|
builder.push(" WHEN 'small_airport' THEN 3 ");
|
||||||
|
builder.push(" WHEN 'seaplane_base' THEN 4 ");
|
||||||
|
builder.push(" WHEN 'heliport' THEN 5 ");
|
||||||
|
builder.push(" WHEN 'balloon_port' THEN 6 ");
|
||||||
|
builder.push(" WHEN 'unknown' THEN 7 ");
|
||||||
|
builder.push(" ELSE 8 END");
|
||||||
|
|
||||||
// Apply pagination.
|
// Apply pagination.
|
||||||
if let Some(limit) = query.limit {
|
if let Some(limit) = query.limit {
|
||||||
@@ -361,8 +406,12 @@ impl Airport {
|
|||||||
&query.municipalities,
|
&query.municipalities,
|
||||||
);
|
);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
|
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "name", &query.names);
|
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
||||||
|
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
|
||||||
|
if let Err(err) = Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds) {
|
||||||
|
log::error!("Error parsing bounds string: {}", err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
let sql_query = builder.build_query_scalar();
|
let sql_query = builder.build_query_scalar();
|
||||||
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
|
sql_query.fetch_one(pool).await.unwrap_or_else(|_| 0)
|
||||||
@@ -529,4 +578,56 @@ impl Airport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_condition_like<'a>(
|
||||||
|
builder: &mut QueryBuilder<'a, Postgres>,
|
||||||
|
has_where: &mut bool,
|
||||||
|
column: &str,
|
||||||
|
field: &'a Option<String>,
|
||||||
|
) {
|
||||||
|
// Query column like
|
||||||
|
if let Some(ref value) = field {
|
||||||
|
if !*has_where {
|
||||||
|
builder.push(" WHERE ");
|
||||||
|
*has_where = true;
|
||||||
|
} else {
|
||||||
|
builder.push(" AND ");
|
||||||
|
}
|
||||||
|
// Using ILIKE with wildcards for partial matching
|
||||||
|
builder
|
||||||
|
.push(column)
|
||||||
|
.push(" ILIKE ")
|
||||||
|
.push_bind(format!("%{}%", value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_condition_bounds<'a>(
|
||||||
|
builder: &mut QueryBuilder<'a, Postgres>,
|
||||||
|
has_where: &mut bool,
|
||||||
|
field: &'a Option<String>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
// Query bounds
|
||||||
|
if let Some(ref bounds_string) = field {
|
||||||
|
if !*has_where {
|
||||||
|
builder.push(" WHERE ");
|
||||||
|
*has_where = true;
|
||||||
|
} else {
|
||||||
|
builder.push(" AND ");
|
||||||
|
}
|
||||||
|
let bounds = Bounds::parse(bounds_string)?;
|
||||||
|
builder
|
||||||
|
.push("(")
|
||||||
|
.push("latitude BETWEEN ")
|
||||||
|
.push_bind(bounds.south_west_lat)
|
||||||
|
.push(" AND ")
|
||||||
|
.push_bind(bounds.north_east_lat)
|
||||||
|
.push(" AND ")
|
||||||
|
.push("longitude BETWEEN ")
|
||||||
|
.push_bind(bounds.south_west_lon)
|
||||||
|
.push(" AND ")
|
||||||
|
.push_bind(bounds.north_east_lon)
|
||||||
|
.push(")");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::sync::OnceLock;
|
|||||||
use actix_web::{
|
use actix_web::{
|
||||||
post, web, HttpResponse, ResponseError,
|
post, web, HttpResponse, ResponseError,
|
||||||
cookie::{Cookie, time::Duration},
|
cookie::{Cookie, time::Duration},
|
||||||
HttpRequest,
|
HttpRequest, put,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{verify_hash, Session, SESSION_COOKIE_NAME},
|
auth::{verify_hash, Session, SESSION_COOKIE_NAME},
|
||||||
@@ -10,7 +10,9 @@ use crate::{
|
|||||||
users::{LoginRequest, RegisterRequest, User, UserResponse},
|
users::{LoginRequest, RegisterRequest, User, UserResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{Auth, DEFAULT_SESSION_TTL};
|
use crate::auth::{hash, Auth, DEFAULT_SESSION_TTL};
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::users::UpdateUser;
|
||||||
|
|
||||||
#[post("/register")]
|
#[post("/register")]
|
||||||
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
|
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
|
||||||
@@ -132,21 +134,61 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
|||||||
HttpResponse::Ok().cookie(session_cookie).finish()
|
HttpResponse::Ok().cookie(session_cookie).finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/key")]
|
#[put("/password")]
|
||||||
async fn create_api_key(req: HttpRequest, auth: Auth) -> HttpResponse {
|
async fn change_password(
|
||||||
|
password: web::Json<String>,
|
||||||
|
req: HttpRequest,
|
||||||
|
auth: Auth,
|
||||||
|
) -> HttpResponse {
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let api_key = Session::new(128, &auth.user.email, &ip_address, None);
|
let email = auth.user.email;
|
||||||
|
|
||||||
// TODO: store api key
|
if let None = User::select(&email).await {
|
||||||
HttpResponse::Ok().body(api_key.session_id)
|
return HttpResponse::Unauthorized().finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_user = UpdateUser {
|
||||||
|
email: None,
|
||||||
|
password: Some(password.into_inner()),
|
||||||
|
role: None,
|
||||||
|
first_name: None,
|
||||||
|
last_name: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match update_user.update(&email).await {
|
||||||
|
Ok(user) => {
|
||||||
|
let response: UserResponse = user.into();
|
||||||
|
log::info!(
|
||||||
|
"Successful password change attempt [Email: {}] [IP Address: {}]",
|
||||||
|
&email,
|
||||||
|
ip_address
|
||||||
|
);
|
||||||
|
HttpResponse::Ok().json(response)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!(
|
||||||
|
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}",
|
||||||
|
&email,
|
||||||
|
ip_address,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
ResponseError::error_response(&Error::new(500, err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/password-reset")]
|
||||||
|
async fn password_reset(req: HttpRequest, auth: Auth) -> HttpResponse {
|
||||||
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
|
HttpResponse::Ok().finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_routes(config: &mut web::ServiceConfig) {
|
pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||||
config.service(
|
config.service(
|
||||||
web::scope("auth")
|
web::scope("account")
|
||||||
.service(register)
|
.service(register)
|
||||||
.service(login)
|
.service(login)
|
||||||
.service(logout)
|
.service(logout)
|
||||||
.service(create_api_key),
|
.service(change_password),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ impl From<core::num::ParseIntError> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<core::num::ParseFloatError> for Error {
|
||||||
|
fn from(error: core::num::ParseFloatError) -> Self {
|
||||||
|
Self::new(500, format!("Parse error: {}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<std::env::VarError> for Error {
|
impl From<std::env::VarError> for Error {
|
||||||
fn from(error: std::env::VarError) -> Self {
|
fn from(error: std::env::VarError) -> Self {
|
||||||
Self::new(
|
Self::new(
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
log::debug!("Creating default administrator");
|
log::debug!("Creating default administrator");
|
||||||
let password = admin_password.unwrap();
|
let password = admin_password.unwrap();
|
||||||
let password_hash = hash(&password)?;
|
let password_hash = hash(&password)?;
|
||||||
|
if email == "admin@example.com" || password == "CHANGEME" {
|
||||||
|
log::warn!(
|
||||||
|
"Default admin credentials are in use, update the ADMIN_EMAIL and ADMIN_PASSWORD."
|
||||||
|
);
|
||||||
|
}
|
||||||
let admin_user = User {
|
let admin_user = User {
|
||||||
email,
|
email,
|
||||||
password_hash,
|
password_hash,
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ impl MetarRow {
|
|||||||
raw_text,
|
raw_text,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4)
|
||||||
"#,
|
"#,
|
||||||
TABLE_NAME,
|
TABLE_NAME,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Postgres, QueryBuilder};
|
||||||
use crate::{auth::hash, error::ApiResult};
|
use crate::{auth::hash, error::ApiResult};
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
|
||||||
@@ -8,9 +8,6 @@ pub const ADMIN_ROLE: &str = "ADMIN";
|
|||||||
pub const USER_ROLE: &str = "USER";
|
pub const USER_ROLE: &str = "USER";
|
||||||
const TABLE_NAME: &str = "users";
|
const TABLE_NAME: &str = "users";
|
||||||
|
|
||||||
/**
|
|
||||||
* RegisterRequest
|
|
||||||
*/
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RegisterRequest {
|
pub struct RegisterRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -34,18 +31,12 @@ impl RegisterRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* LoginRequest
|
|
||||||
*/
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UserResponse
|
|
||||||
*/
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -65,6 +56,75 @@ impl From<User> for UserResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct UpdateUser {
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub first_name: Option<String>,
|
||||||
|
pub last_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateUser {
|
||||||
|
pub async fn update(&self, email: &str) -> ApiResult<User> {
|
||||||
|
let pool = db::pool();
|
||||||
|
|
||||||
|
let mut query_builder: QueryBuilder<Postgres> =
|
||||||
|
QueryBuilder::new(&format!("UPDATE {} SET ", TABLE_NAME));
|
||||||
|
|
||||||
|
let mut first_clause = true;
|
||||||
|
|
||||||
|
let mut push_comma = |query_builder: &mut QueryBuilder<Postgres>| {
|
||||||
|
if !first_clause {
|
||||||
|
query_builder.push(", ");
|
||||||
|
} else {
|
||||||
|
first_clause = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref email) = self.email {
|
||||||
|
push_comma(&mut query_builder);
|
||||||
|
query_builder.push("email = ");
|
||||||
|
query_builder.push_bind(email);
|
||||||
|
}
|
||||||
|
if let Some(ref password) = self.password {
|
||||||
|
push_comma(&mut query_builder);
|
||||||
|
let password_hash = hash(password)?;
|
||||||
|
query_builder.push("password_hash = ");
|
||||||
|
query_builder.push_bind(password_hash);
|
||||||
|
}
|
||||||
|
if let Some(ref role) = self.role {
|
||||||
|
push_comma(&mut query_builder);
|
||||||
|
query_builder.push("role = ");
|
||||||
|
query_builder.push_bind(role);
|
||||||
|
}
|
||||||
|
if let Some(ref first_name) = self.first_name {
|
||||||
|
push_comma(&mut query_builder);
|
||||||
|
query_builder.push("first_name = ");
|
||||||
|
query_builder.push_bind(first_name);
|
||||||
|
}
|
||||||
|
if let Some(ref last_name) = self.last_name {
|
||||||
|
push_comma(&mut query_builder);
|
||||||
|
query_builder.push("last_name = ");
|
||||||
|
query_builder.push_bind(last_name);
|
||||||
|
}
|
||||||
|
push_comma(&mut query_builder);
|
||||||
|
query_builder.push("updated_at = ");
|
||||||
|
query_builder.push_bind(Utc::now());
|
||||||
|
|
||||||
|
query_builder.push(" WHERE email = ");
|
||||||
|
query_builder.push_bind(email.to_string());
|
||||||
|
query_builder.push(" RETURNING *");
|
||||||
|
|
||||||
|
dbg!(&query_builder.sql());
|
||||||
|
|
||||||
|
let query = query_builder.build_query_as::<User>();
|
||||||
|
let user = query.fetch_one(pool).await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Debug)]
|
#[derive(Serialize, Deserialize, sqlx::FromRow, Debug)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
|||||||
20
bruno/Users/Change Password.bru
Normal file
20
bruno/Users/Change Password.bru
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
meta {
|
||||||
|
name: Change Password
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{BASE_URL}}/account/password
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
"New Password"
|
||||||
|
}
|
||||||
|
|
||||||
|
script:post-response {
|
||||||
|
const apiKey = res.body
|
||||||
|
bru.setVar("bearer",apiKey)
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Create API Key
|
|
||||||
type: http
|
|
||||||
seq: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{BASE_URL}}/auth/key
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"email": "john.doe@gmail.com",
|
|
||||||
"password": "fake_password123"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
const apiKey = res.body
|
|
||||||
bru.setVar("bearer",apiKey)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: {{BASE_URL}}/auth/login
|
url: {{BASE_URL}}/account/login
|
||||||
body: json
|
body: json
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: {{BASE_URL}}/auth/logout
|
url: {{BASE_URL}}/account/logout
|
||||||
body: none
|
body: none
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: {{BASE_URL}}/auth/register
|
url: {{BASE_URL}}/account/register
|
||||||
body: json
|
body: json
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,26 @@ body,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Set up Flexbox layout */
|
/* Set up Flexbox layout */
|
||||||
.App {
|
.App {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh; /* Full viewport height */
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make the map container fill the remaining space */
|
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
flex: 1 1 auto; /* Allow the map to grow and fill space */
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*.leaflet-container {*/
|
|
||||||
/* width: 100%;*/
|
|
||||||
/* height: 100vh;*/
|
|
||||||
/*}*/
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ 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';
|
||||||
// import { Header } from '@components/Header';
|
|
||||||
|
|
||||||
// Fix for default marker icon issues in React-Leaflet
|
// Fix for default marker icon issues in React-Leaflet
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
import { Header } from '@components/Header';
|
||||||
|
import AirportLayer from '@components/AirportLayer.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
|
||||||
@@ -26,21 +27,24 @@ const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className='App'>
|
<div className='App'>
|
||||||
{/*<Header />*/}
|
<Header />
|
||||||
<MapContainer
|
<div className='map-wrapper'>
|
||||||
className='leaflet-container'
|
<MapContainer
|
||||||
center={[38.944444, -77.455833]}
|
className='leaflet-container'
|
||||||
zoom={6}
|
center={[38.944444, -77.455833]}
|
||||||
minZoom={3}
|
zoom={6}
|
||||||
maxZoom={19}
|
minZoom={3}
|
||||||
maxBounds={[
|
maxZoom={19}
|
||||||
[-85.06, -180],
|
maxBounds={[
|
||||||
[85.06, 180]
|
[-85.06, -180],
|
||||||
]}
|
[85.06, 180]
|
||||||
scrollWheelZoom={true}
|
]}
|
||||||
>
|
scrollWheelZoom={true}
|
||||||
<TileLayer url={tileLayerUrl} />
|
>
|
||||||
</MapContainer>
|
<TileLayer url={tileLayerUrl} />
|
||||||
|
<AirportLayer />
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
ui/src/components/AirportLayer.tsx
Normal file
104
ui/src/components/AirportLayer.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||||
|
import { Marker, Popup, useMapEvents } from 'react-leaflet';
|
||||||
|
import { getAirports } from '@lib/airport.ts';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
interface Bounds {
|
||||||
|
northEast: { lat: number; lon: number };
|
||||||
|
southWest: { lat: number; lon: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirportLayer() {
|
||||||
|
const [airports, setAirports] = useState<Airport[]>([]);
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
moveend: (event) => {
|
||||||
|
const map = event.target;
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
|
||||||
|
const boundsParam: Bounds = {
|
||||||
|
northEast: {
|
||||||
|
lat: bounds.getNorth(),
|
||||||
|
lon: bounds.getEast()
|
||||||
|
},
|
||||||
|
southWest: {
|
||||||
|
lat: bounds.getSouth(),
|
||||||
|
lon: bounds.getWest()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call getAirports with the current map bounds and desired parameters.
|
||||||
|
getAirports({
|
||||||
|
bounds: boundsParam,
|
||||||
|
metars: true,
|
||||||
|
categories: [AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE],
|
||||||
|
limit: 200
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
setAirports(response.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error fetching airports:', error);
|
||||||
|
setAirports([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{airports.map((airport, index) => {
|
||||||
|
const markerColor = getMarkerColor(airport);
|
||||||
|
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]
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
.header {
|
.header {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
margin-bottom: 120px;
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Burger, Container, Group, Text } from '@mantine/core';
|
import { Avatar, Burger, 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 = [
|
||||||
{ link: '/', label: 'Map' },
|
{ link: '/', label: 'Map' },
|
||||||
{ link: '/airports', label: 'Airports' },
|
{ link: '/airports', label: 'Airports' },
|
||||||
{ link: '/metars', label: 'METARs' }
|
{ link: '/metars', label: 'Metars' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
@@ -31,7 +32,11 @@ export function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className={classes.header}>
|
<header className={classes.header}>
|
||||||
<Container size='md' className={classes.inner}>
|
<Container size='md' className={classes.inner}>
|
||||||
<Text>Aviation Weather</Text>
|
<span style={{ display: 'flex', flexDirection: 'row' }}>
|
||||||
|
<Text>Aviation Weather</Text>
|
||||||
|
<Avatar src='../../../public/logo.svg' alt="it's me" />
|
||||||
|
</span>
|
||||||
|
{/*<Logo />*/}
|
||||||
<Group gap={5} visibleFrom='xs'>
|
<Group gap={5} visibleFrom='xs'>
|
||||||
{items}
|
{items}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
40
ui/src/lib/airport.ts
Normal file
40
ui/src/lib/airport.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Airport, AirportCategory, Bounds, GetAirportsResponse } from '@lib/airport.types.ts';
|
||||||
|
import { getRequest } from '@lib/index.ts';
|
||||||
|
|
||||||
|
export async function getAirport({ icao }: { icao: string }): Promise<Airport> {
|
||||||
|
const response = await getRequest(`airports/${icao}`);
|
||||||
|
return response?.json() || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetAirportsParameters {
|
||||||
|
icaos?: string[];
|
||||||
|
name?: string;
|
||||||
|
categories?: AirportCategory[];
|
||||||
|
bounds?: Bounds;
|
||||||
|
metars?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAirports({
|
||||||
|
icaos,
|
||||||
|
name,
|
||||||
|
categories,
|
||||||
|
bounds,
|
||||||
|
metars = false,
|
||||||
|
limit = 1000,
|
||||||
|
page = 1
|
||||||
|
}: GetAirportsParameters): 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,
|
||||||
|
metars: metars ?? undefined,
|
||||||
|
limit,
|
||||||
|
page
|
||||||
|
});
|
||||||
|
return response?.json() || { data: [] };
|
||||||
|
}
|
||||||
93
ui/src/lib/airport.types.ts
Normal file
93
ui/src/lib/airport.types.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Metar } from './metar.types';
|
||||||
|
|
||||||
|
export enum AirportCategory {
|
||||||
|
SMALL = 'small_airport',
|
||||||
|
MEDIUM = 'medium_airport',
|
||||||
|
LARGE = 'large_airport',
|
||||||
|
HELIPORT = 'heliport',
|
||||||
|
BALLOONPORT = 'balloon_port',
|
||||||
|
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 '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 {
|
||||||
|
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[];
|
||||||
|
limit: number;
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
72
ui/src/lib/index.ts
Normal file
72
ui/src/lib/index.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||||
|
// const servicePort = process.env.SERVICE_PORT || 5000;'
|
||||||
|
// const baseURL = `${serviceHost}:${servicePort}`;
|
||||||
|
const baseUrl = 'http://localhost:5000';
|
||||||
|
|
||||||
|
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||||
|
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}`;
|
||||||
|
return await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
43
ui/src/lib/metar.types.ts
Normal file
43
ui/src/lib/metar.types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user