Header and login
This commit is contained in:
@@ -19,3 +19,5 @@ ACCESS_TOKEN_MAXAGE=5
|
|||||||
REFRESH_TOKEN_PRIVATE_KEY=
|
REFRESH_TOKEN_PRIVATE_KEY=
|
||||||
REFRESH_TOKEN_PUBLIC_KEY=
|
REFRESH_TOKEN_PUBLIC_KEY=
|
||||||
REFRESH_TOKEN_MAXAGE=30
|
REFRESH_TOKEN_MAXAGE=30
|
||||||
|
|
||||||
|
GOV_API_URL=https://aviationweather.gov/cgi-bin/data
|
||||||
|
|||||||
9
service/Cargo.lock
generated
9
service/Cargo.lock
generated
@@ -1139,9 +1139,9 @@ checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.7"
|
version = "0.4.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128"
|
checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "local-channel"
|
name = "local-channel"
|
||||||
@@ -1665,9 +1665,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.13"
|
version = "0.38.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662"
|
checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.4.0",
|
"bitflags 2.4.0",
|
||||||
"errno",
|
"errno",
|
||||||
@@ -1819,6 +1819,7 @@ dependencies = [
|
|||||||
"r2d2",
|
"r2d2",
|
||||||
"redis",
|
"redis",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustix",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ argon2 = "0.5.2"
|
|||||||
jsonwebtoken = "9.0.0"
|
jsonwebtoken = "9.0.0"
|
||||||
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
|
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
|
||||||
base64 = "0.21.4"
|
base64 = "0.21.4"
|
||||||
|
rustix = "0.38.19" # https://github.com/imsnif/bandwhich/issues/284
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
profile_picture TEXT,
|
profile_picture TEXT,
|
||||||
|
favorites TEXT[] NOT NULL DEFAULT '{}',
|
||||||
verified BOOLEAN NOT NULL DEFAULT FALSE
|
verified BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{airports::{InsertAirport, QueryAirport}, db::{self, Metadata}};
|
use crate::{airports::{InsertAirport, QueryAirport}, db::{self, Metadata}, auth::{JwtAuth, verify_role}};
|
||||||
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest};
|
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
|
||||||
use log::{error, warn};
|
use log::{error, warn};
|
||||||
use postgis_diesel::types::{Polygon, Point};
|
use postgis_diesel::types::{Polygon, Point};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
@@ -14,7 +14,11 @@ struct GetAllParameters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/import")]
|
#[get("/import")]
|
||||||
async fn import() -> HttpResponse {
|
async fn import(auth: JwtAuth) -> HttpResponse {
|
||||||
|
let _ = match verify_role(&auth, "admin") {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
};
|
||||||
db::import_data();
|
db::import_data();
|
||||||
HttpResponse::Ok().body({})
|
HttpResponse::Ok().body({})
|
||||||
}
|
}
|
||||||
@@ -129,7 +133,11 @@ async fn get(icao: web::Path<String>) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/airports")]
|
#[post("/airports")]
|
||||||
async fn create(airport: web::Json<InsertAirport>) -> HttpResponse {
|
async fn create(airport: web::Json<InsertAirport>, auth: JwtAuth) -> HttpResponse {
|
||||||
|
let _ = match verify_role(&auth, "admin") {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
};
|
||||||
match QueryAirport::create(airport.into_inner()) {
|
match QueryAirport::create(airport.into_inner()) {
|
||||||
Ok(a) => HttpResponse::Created().json(a),
|
Ok(a) => HttpResponse::Created().json(a),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -140,7 +148,11 @@ async fn create(airport: web::Json<InsertAirport>) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/airports/{icao}")]
|
#[put("/airports/{icao}")]
|
||||||
async fn update(icao: web::Path<i32>, airport: web::Json<InsertAirport>) -> HttpResponse {
|
async fn update(icao: web::Path<i32>, airport: web::Json<InsertAirport>, auth: JwtAuth) -> HttpResponse {
|
||||||
|
let _ = match verify_role(&auth, "admin") {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
};
|
||||||
match QueryAirport::update(icao.into_inner(), airport.into_inner()) {
|
match QueryAirport::update(icao.into_inner(), airport.into_inner()) {
|
||||||
Ok(a) => HttpResponse::Ok().json(a),
|
Ok(a) => HttpResponse::Ok().json(a),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -151,7 +163,11 @@ async fn update(icao: web::Path<i32>, airport: web::Json<InsertAirport>) -> Http
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/airports/{icao}")]
|
#[delete("/airports/{icao}")]
|
||||||
async fn delete(icao: web::Path<i32>) -> HttpResponse {
|
async fn delete(icao: web::Path<i32>, auth: JwtAuth) -> HttpResponse {
|
||||||
|
let _ = match verify_role(&auth, "admin") {
|
||||||
|
Ok(_) => {},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
};
|
||||||
match QueryAirport::delete(icao.into_inner()) {
|
match QueryAirport::delete(icao.into_inner()) {
|
||||||
Ok(_) => HttpResponse::NoContent().finish(),
|
Ok(_) => HttpResponse::NoContent().finish(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ impl RegisterUser {
|
|||||||
updated_at: chrono::Utc::now().naive_utc(),
|
updated_at: chrono::Utc::now().naive_utc(),
|
||||||
created_at: chrono::Utc::now().naive_utc(),
|
created_at: chrono::Utc::now().naive_utc(),
|
||||||
profile_picture: None,
|
profile_picture: None,
|
||||||
|
favorites: vec![],
|
||||||
verified: false,
|
verified: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ pub struct QueryUser {
|
|||||||
pub updated_at: chrono::NaiveDateTime,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub profile_picture: Option<String>,
|
pub profile_picture: Option<String>,
|
||||||
|
pub favorites: Vec<String>,
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ pub struct InsertUser {
|
|||||||
pub updated_at: chrono::NaiveDateTime,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub profile_picture: Option<String>,
|
pub profile_picture: Option<String>,
|
||||||
|
pub favorites: Vec<String>,
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ impl InsertUser {
|
|||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_profile(email: &str, profile_picture: Option<&str>) -> Result<QueryUser, ServiceError> {
|
pub fn update_profile_picture(email: &str, profile_picture: Option<&str>) -> Result<QueryUser, ServiceError> {
|
||||||
let mut conn = connection()?;
|
let mut conn = connection()?;
|
||||||
let user = diesel::update(users::table)
|
let user = diesel::update(users::table)
|
||||||
.filter(users::email.eq(&email))
|
.filter(users::email.eq(&email))
|
||||||
@@ -98,6 +101,15 @@ impl InsertUser {
|
|||||||
.get_result(&mut conn)?;
|
.get_result(&mut conn)?;
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_favorites(email: &str, favorites: Vec<String>) -> Result<QueryUser, ServiceError> {
|
||||||
|
let mut conn = connection()?;
|
||||||
|
let user = diesel::update(users::table)
|
||||||
|
.filter(users::email.eq(&email))
|
||||||
|
.set(users::favorites.eq(favorites))
|
||||||
|
.get_result(&mut conn)?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use redis::{Client as RedisClient, aio::Connection as RedisConnection};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::diesel_migrations::MigrationHarness;
|
use crate::diesel_migrations::MigrationHarness;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use log::{error, debug, info, warn};
|
use log::{error, debug, info};
|
||||||
use r2d2;
|
use r2d2;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ diesel::table! {
|
|||||||
updated_at -> Timestamp,
|
updated_at -> Timestamp,
|
||||||
created_at -> Timestamp,
|
created_at -> Timestamp,
|
||||||
profile_picture -> Nullable<Text>,
|
profile_picture -> Nullable<Text>,
|
||||||
|
favorites -> Array<Text>,
|
||||||
verified -> Bool,
|
verified -> Bool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ mod scheduler;
|
|||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info"));
|
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,service=info"));
|
||||||
db::init();
|
db::init();
|
||||||
scheduler::update_airports();
|
scheduler::update_airports();
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_remote_metars(icaos: String) -> Vec<Metar> {
|
async fn get_remote_metars(icaos: String) -> Vec<Metar> {
|
||||||
let url = format!("https://beta.aviationweather.gov/cgi-bin/data/metar.php?ids={}&format=xml", icaos);
|
let gov_api_url = std::env::var("GOV_API_URL").expect("GOV_API_URL must be set");
|
||||||
|
let url = format!("{}/metar.php?ids={}&format=xml", gov_api_url, icaos);
|
||||||
match reqwest::get(url).await {
|
match reqwest::get(url).await {
|
||||||
Ok(r) => match r.text().await {
|
Ok(r) => match r.text().await {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
@@ -290,7 +291,7 @@ impl Metar {
|
|||||||
return Ok(db_metars);
|
return Ok(db_metars);
|
||||||
}
|
}
|
||||||
trace!("Retrieving missing METAR data for {:?}", missing_icaos);
|
trace!("Retrieving missing METAR data for {:?}", missing_icaos);
|
||||||
let missing_icaos_string: Vec<String> = missing_icaos.iter().map(|icao| format!("'{}'", icao.to_string())).collect();
|
let missing_icaos_string: Vec<String> = missing_icaos.iter().map(|icao| format!("{}", icao.to_string())).collect();
|
||||||
let mut missing_metars = Self::get_remote_metars(missing_icaos_string.join(",")).await;
|
let mut missing_metars = Self::get_remote_metars(missing_icaos_string.join(",")).await;
|
||||||
if missing_metars.len() > 0 {
|
if missing_metars.len() > 0 {
|
||||||
let insert_metars = Self::to_insert(&missing_metars);
|
let insert_metars = Self::to_insert(&missing_metars);
|
||||||
|
|||||||
@@ -1,18 +1,59 @@
|
|||||||
use actix_web::{get, post, delete, web, HttpResponse};
|
use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
|
||||||
|
|
||||||
|
use crate::auth::{JwtAuth, QueryUser, InsertUser};
|
||||||
|
|
||||||
#[get("users/favorites")]
|
#[get("users/favorites")]
|
||||||
async fn get_favorites() -> HttpResponse {
|
async fn get_favorites(auth: JwtAuth) -> HttpResponse {
|
||||||
HttpResponse::NotImplemented().finish()
|
println!("{:?}", auth);
|
||||||
|
match QueryUser::get_by_email(&auth.user.email) {
|
||||||
|
Ok(user) => {
|
||||||
|
return HttpResponse::Ok().json(user.favorites)
|
||||||
|
},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("users/favorites")]
|
#[post("users/favorites/{icao}")]
|
||||||
async fn add_favorite() -> HttpResponse {
|
async fn add_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
||||||
HttpResponse::NotImplemented().finish()
|
match QueryUser::get_by_email(&auth.user.email) {
|
||||||
|
Ok(user) => {
|
||||||
|
if user.favorites.contains(&icao) {
|
||||||
|
// Check if the airport ICAO is already in the user's favorites
|
||||||
|
return HttpResponse::Conflict().finish()
|
||||||
|
} else {
|
||||||
|
// Add the airport ICAO to the user's favorites
|
||||||
|
let mut favorites = user.favorites;
|
||||||
|
favorites.push(icao.into_inner());
|
||||||
|
match InsertUser::update_favorites(&user.email, favorites) {
|
||||||
|
Ok(_) => return HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("users/favorites")]
|
#[delete("users/favorites/{icao}")]
|
||||||
async fn delete_favorite() -> HttpResponse {
|
async fn delete_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
||||||
HttpResponse::NotImplemented().finish()
|
let icao: String = icao.into_inner();
|
||||||
|
match QueryUser::get_by_email(&auth.user.email) {
|
||||||
|
Ok(user) => {
|
||||||
|
if user.favorites.contains(&icao) {
|
||||||
|
// Check if the airport ICAO is already in the user's favorites
|
||||||
|
let mut favorites = user.favorites;
|
||||||
|
favorites.retain(|x| x != &icao);
|
||||||
|
match InsertUser::update_favorites(&user.email, favorites) {
|
||||||
|
Ok(_) => return HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove the airport ICAO from the user's favorites
|
||||||
|
return HttpResponse::Conflict().finish()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_routes(config: &mut web::ServiceConfig) {
|
pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
SERVICE_HOST=service
|
SERVICE_HOST=service
|
||||||
SERVICE_PORT=5000
|
SERVICE_PORT=5000
|
||||||
|
|
||||||
UI_PORT=8080
|
UI_PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-development}
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
ports:
|
ports:
|
||||||
- ${UI_PORT:-8080}:3000
|
- ${UI_PORT:-3000}:3000
|
||||||
build:
|
build:
|
||||||
context: ./
|
context: ./
|
||||||
target: dev
|
target: dev
|
||||||
@@ -23,4 +23,4 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
weather-frontend: {}
|
weather-frontend:
|
||||||
|
|||||||
1300
ui/package-lock.json
generated
1300
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.1.2",
|
"@mantine/core": "^7.1.2",
|
||||||
|
"@mantine/form": "^7.1.2",
|
||||||
"@mantine/hooks": "^7.1.2",
|
"@mantine/hooks": "^7.1.2",
|
||||||
"@mantine/modals": "^7.1.2",
|
"@mantine/modals": "^7.1.2",
|
||||||
|
"@mantine/notifications": "^7.1.2",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "^13.5.4",
|
"next": "^13.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
"recoil": "^0.7.7"
|
"recoil": "^0.7.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.5",
|
||||||
"@types/leaflet": "^1.9.6",
|
"@types/leaflet": "^1.9.6",
|
||||||
"@types/node": "20.8.2",
|
"@types/node": "20.8.2",
|
||||||
"@types/react": "18.2.24",
|
"@types/react": "18.2.24",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface GetAirportProps {
|
|||||||
|
|
||||||
export async function getAirport({ icao }: GetAirportProps): Promise<GetAirportResponse> {
|
export async function getAirport({ icao }: GetAirportProps): Promise<GetAirportResponse> {
|
||||||
const response = await getRequest(`airports/${icao}`, {});
|
const response = await getRequest(`airports/${icao}`, {});
|
||||||
return response?.data || { data: undefined };
|
return response?.json() || { data: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetAirportsProps {
|
interface GetAirportsProps {
|
||||||
@@ -34,5 +34,5 @@ export async function getAirports({
|
|||||||
limit,
|
limit,
|
||||||
page
|
page
|
||||||
});
|
});
|
||||||
return response?.data || { data: [] };
|
return response?.json() || { data: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
63
ui/src/api/auth.ts
Normal file
63
ui/src/api/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
19
ui/src/api/auth.types.ts
Normal file
19
ui/src/api/auth.types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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,18 +1,58 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
|
||||||
|
|
||||||
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||||
const servicePort = process.env.SERVICE_PORT || 5000;
|
const servicePort = process.env.SERVICE_PORT || 5000;
|
||||||
|
const baseURL = `${serviceHost}:${servicePort}`;
|
||||||
|
|
||||||
export async function getRequest(endpoint: string, params: any): Promise<AxiosResponse<any, any> | undefined> {
|
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||||
const response = await axios
|
// Remove undefined params
|
||||||
.get(`${serviceHost}:${servicePort}/${endpoint}`, { params })
|
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||||
.catch((error) => console.error(error));
|
const urlParams = new URLSearchParams(params);
|
||||||
return response || undefined;
|
const url = urlParams && urlParams.size > 0 ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postRequest(endpoint: string, body: any): Promise<AxiosResponse<any, any> | undefined> {
|
interface PostOptions {
|
||||||
const response = await axios
|
headers?: Record<string, any>;
|
||||||
.post(`${serviceHost}:${servicePort}/${endpoint}`, { body })
|
type?: 'json' | 'form';
|
||||||
.catch((error) => console.error(error));
|
}
|
||||||
return response || undefined;
|
|
||||||
|
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 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;
|
||||||
|
pages: number;
|
||||||
|
total: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ export async function getMetars(icaos: string[]): Promise<GetMetarsResponse> {
|
|||||||
}
|
}
|
||||||
const stationICAOs: string = icaos.map((icao) => icao).join(',');
|
const stationICAOs: string = icaos.map((icao) => icao).join(',');
|
||||||
const response = await getRequest(`metars/${stationICAOs}`, {});
|
const response = await getRequest(`metars/${stationICAOs}`, {});
|
||||||
return response?.data || { data: [] };
|
return response?.json() || { data: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
51
ui/src/api/users.ts
Normal file
51
ui/src/api/users.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { deleteRequest, getRequest, postRequest } from '.';
|
||||||
|
|
||||||
|
export async function getPicture(): Promise<Blob | undefined> {
|
||||||
|
const response = await getRequest('users/picture');
|
||||||
|
if (response?.status === 200) {
|
||||||
|
return 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,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import RecoilRootWrapper from '@app/recoil-root-wrapper';
|
import RecoilRootWrapper from '@app/recoil-root-wrapper';
|
||||||
import Sidebar from '@/components/Sidebar';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import Topbar from '@/components/Topbar';
|
import Header from '@/components/Header';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { MantineProvider } from '@mantine/core';
|
import { MantineProvider } from '@mantine/core';
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
@@ -26,7 +26,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<RecoilRootWrapper>
|
<RecoilRootWrapper>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<Topbar />
|
<Header />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{children}
|
{children}
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
|
|||||||
260
ui/src/components/Header/HeaderModal.tsx
Normal file
260
ui/src/components/Header/HeaderModal.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { login, register, refreshLoggedIn } from '@/api/auth';
|
||||||
|
import { User } from '@/api/auth.types';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
Anchor,
|
||||||
|
Paper,
|
||||||
|
TextInput,
|
||||||
|
Button,
|
||||||
|
PasswordInput,
|
||||||
|
Group,
|
||||||
|
Checkbox,
|
||||||
|
Text
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
|
interface HeaderModalProps {
|
||||||
|
type?: string;
|
||||||
|
toggle: any;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
setRefreshId: (id: NodeJS.Timeout) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderModal({ type, toggle, setUser, setRefreshId }: 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: '',
|
||||||
|
password: '',
|
||||||
|
remember: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 id = notifications.show({
|
||||||
|
loading: true,
|
||||||
|
title: `Creating account`,
|
||||||
|
message: `Please wait...`,
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: false
|
||||||
|
});
|
||||||
|
const registerResponse = await register({
|
||||||
|
first_name: values.firstName,
|
||||||
|
last_name: values.lastName,
|
||||||
|
email: values.email,
|
||||||
|
password: values.password
|
||||||
|
});
|
||||||
|
if (registerResponse) {
|
||||||
|
const loginResponse = await login(values.email, values.password);
|
||||||
|
if (loginResponse) {
|
||||||
|
setUser(loginResponse.user);
|
||||||
|
setRefreshId(refreshLoggedIn());
|
||||||
|
onClose();
|
||||||
|
notifications.update({
|
||||||
|
id,
|
||||||
|
title: `Account created`,
|
||||||
|
message: `Welcome ${loginResponse.user.first_name}!`,
|
||||||
|
color: 'green',
|
||||||
|
autoClose: 2000,
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
} 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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) => {
|
||||||
|
const response = await login(values.email, values.password);
|
||||||
|
if (response) {
|
||||||
|
setUser(response.user);
|
||||||
|
setRefreshId(refreshLoggedIn());
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
title: `Unable to Login`,
|
||||||
|
message: `Please try again.`,
|
||||||
|
color: 'red',
|
||||||
|
autoClose: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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' {...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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
ui/src/components/Header/index.tsx
Normal file
215
ui/src/components/Header/index.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAirports } from '@/api/airport';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Autocomplete, Avatar, Button, Card, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core';
|
||||||
|
import './header.css';
|
||||||
|
import { refresh, refreshLoggedIn, logout } from '@/api/auth';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
import { userState } from '@/state/auth';
|
||||||
|
import { getFavorites, getPicture, setPicture } from '@/api/users';
|
||||||
|
import { useToggle } from '@mantine/hooks';
|
||||||
|
import { HeaderModal } from './HeaderModal';
|
||||||
|
import { favoritesState } from '@/state/user';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
|
||||||
|
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
||||||
|
const [user, setUser] = useRecoilState(userState);
|
||||||
|
const [favorites, setFavorites] = useRecoilState(favoritesState);
|
||||||
|
const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const [profilePicture, setProfilePicture] = useState<File | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !Cookies.get('logged_in')) {
|
||||||
|
refresh().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setRefreshId(refreshLoggedIn());
|
||||||
|
setUser(response.user);
|
||||||
|
getFavorites().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setFavorites(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (response.user.profile_picture) {
|
||||||
|
getPicture().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setProfilePicture(response as File);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
async function onChange(value: string) {
|
||||||
|
setSearchValue(value);
|
||||||
|
const airportData = await getAirports({ filter: value });
|
||||||
|
setAirports(
|
||||||
|
airportData.data.map((airport) => ({
|
||||||
|
key: airport.icao,
|
||||||
|
value: airport.icao,
|
||||||
|
label: `${airport.icao} - ${airport.full_name}`
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(value: string) {
|
||||||
|
router.push(`/airport/${value}`);
|
||||||
|
setSearchValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
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='user-section'>
|
||||||
|
{user ? (
|
||||||
|
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton className='user user-button'>
|
||||||
|
<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/jpg'
|
||||||
|
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={async () => {
|
||||||
|
await logout();
|
||||||
|
Cookies.remove('logged_in');
|
||||||
|
setUser(undefined);
|
||||||
|
setFavorites([]);
|
||||||
|
setProfilePicture(null);
|
||||||
|
if (refreshId) {
|
||||||
|
clearInterval(refreshId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
setUser={(u) => {
|
||||||
|
setUser(u);
|
||||||
|
getFavorites().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setFavorites(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (u.profile_picture) {
|
||||||
|
getPicture().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
setProfilePicture(response as File);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
setRefreshId={setRefreshId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default function MapTiles() {
|
|||||||
return new DivIcon({
|
return new DivIcon({
|
||||||
html: ReactDOMServer.renderToString(
|
html: ReactDOMServer.renderToString(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<Avatar variant='filled' color={color} radius='xl' size={size}>
|
<Avatar variant='filled' color={color} radius={'xl'} size={size}>
|
||||||
{tag}
|
{tag}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import {
|
|||||||
BsFillCloudSnowFill,
|
BsFillCloudSnowFill,
|
||||||
BsQuestionLg
|
BsQuestionLg
|
||||||
} from 'react-icons/bs';
|
} from 'react-icons/bs';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, Divider, Grid, Modal, Tooltip } from '@mantine/core';
|
import { Card, Divider, Grid, Modal, Tooltip } from '@mantine/core';
|
||||||
import './metars.css';
|
import './metars.css';
|
||||||
import SkyConditions from './SkyConditions';
|
import SkyConditions from './SkyConditions';
|
||||||
|
import { addFavorite, getFavorites, removeFavorite } from '@/api/users';
|
||||||
|
import { favoritesState } from '@/state/user';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
interface MetarModalProps {
|
interface MetarModalProps {
|
||||||
airport: Airport;
|
airport: Airport;
|
||||||
@@ -28,10 +31,20 @@ interface MetarModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) {
|
export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) {
|
||||||
|
const favorites = useRecoilValue(favoritesState);
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
const [isFavorite, setIsFavorite] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsFavorite(favorites.includes(airport.icao));
|
||||||
|
}, [favorites, airport]);
|
||||||
|
|
||||||
function handleFavorite(value: boolean) {
|
function handleFavorite(value: boolean) {
|
||||||
setIsFavorite(value);
|
setIsFavorite(value);
|
||||||
|
if (value) {
|
||||||
|
addFavorite(airport.icao);
|
||||||
|
} else {
|
||||||
|
removeFavorite(airport.icao);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { AiOutlineUser } from 'react-icons/ai';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { getAirports } from '@/api/airport';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Autocomplete, Avatar } from '@mantine/core';
|
|
||||||
import './topbar.css';
|
|
||||||
|
|
||||||
export default function Topbar() {
|
|
||||||
const [searchValue, setSearchValue] = useState('');
|
|
||||||
const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function onChange(value: string) {
|
|
||||||
setSearchValue(value);
|
|
||||||
const airportData = await getAirports({ filter: value });
|
|
||||||
setAirports(
|
|
||||||
airportData.data.map((airport) => ({
|
|
||||||
key: airport.icao,
|
|
||||||
value: airport.icao,
|
|
||||||
label: `${airport.icao} - ${airport.full_name}`
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick(value: string) {
|
|
||||||
router.push(`/airport/${value}`);
|
|
||||||
setSearchValue('');
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
<Link className='avatar' href={'/profile'}>
|
|
||||||
<Avatar variant='filled'>
|
|
||||||
<AiOutlineUser />
|
|
||||||
</Avatar>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
12
ui/src/state/auth.ts
Normal file
12
ui/src/state/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { User } from '@/api/auth.types';
|
||||||
|
import { atom } from 'recoil';
|
||||||
|
|
||||||
|
export const userState = atom({
|
||||||
|
key: 'userState',
|
||||||
|
default: undefined as User | undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isAuthenticatedState = atom({
|
||||||
|
key: 'isAuthenticatedState',
|
||||||
|
default: false
|
||||||
|
});
|
||||||
6
ui/src/state/user.ts
Normal file
6
ui/src/state/user.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from 'recoil';
|
||||||
|
|
||||||
|
export const favoritesState = atom({
|
||||||
|
key: 'favoritesState',
|
||||||
|
default: [] as string[]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user