Header and login
This commit is contained in:
@@ -7,7 +7,7 @@ interface GetAirportProps {
|
||||
|
||||
export async function getAirport({ icao }: GetAirportProps): Promise<GetAirportResponse> {
|
||||
const response = await getRequest(`airports/${icao}`, {});
|
||||
return response?.data || { data: undefined };
|
||||
return response?.json() || { data: undefined };
|
||||
}
|
||||
|
||||
interface GetAirportsProps {
|
||||
@@ -34,5 +34,5 @@ export async function getAirports({
|
||||
limit,
|
||||
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 servicePort = process.env.SERVICE_PORT || 5000;
|
||||
const baseURL = `${serviceHost}:${servicePort}`;
|
||||
|
||||
export async function getRequest(endpoint: string, params: any): Promise<AxiosResponse<any, any> | undefined> {
|
||||
const response = await axios
|
||||
.get(`${serviceHost}:${servicePort}/${endpoint}`, { params })
|
||||
.catch((error) => console.error(error));
|
||||
return response || undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
export async function postRequest(endpoint: string, body: any): Promise<AxiosResponse<any, any> | undefined> {
|
||||
const response = await axios
|
||||
.post(`${serviceHost}:${servicePort}/${endpoint}`, { body })
|
||||
.catch((error) => console.error(error));
|
||||
return response || undefined;
|
||||
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 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 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 RecoilRootWrapper from '@app/recoil-root-wrapper';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Topbar from '@/components/Topbar';
|
||||
import Header from '@/components/Header';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
@@ -26,7 +26,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<RecoilRootWrapper>
|
||||
<MantineProvider>
|
||||
<ModalsProvider>
|
||||
<Topbar />
|
||||
<Header />
|
||||
<Sidebar />
|
||||
{children}
|
||||
</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({
|
||||
html: ReactDOMServer.renderToString(
|
||||
<MantineProvider>
|
||||
<Avatar variant='filled' color={color} radius='xl' size={size}>
|
||||
<Avatar variant='filled' color={color} radius={'xl'} size={size}>
|
||||
{tag}
|
||||
</Avatar>
|
||||
</MantineProvider>
|
||||
|
||||
@@ -16,10 +16,13 @@ import {
|
||||
BsFillCloudSnowFill,
|
||||
BsQuestionLg
|
||||
} from 'react-icons/bs';
|
||||
import { useState } from 'react';
|
||||
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 { useRecoilValue } from 'recoil';
|
||||
|
||||
interface MetarModalProps {
|
||||
airport: Airport;
|
||||
@@ -28,10 +31,20 @@ interface MetarModalProps {
|
||||
}
|
||||
|
||||
export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) {
|
||||
const favorites = useRecoilValue(favoritesState);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(favorites.includes(airport.icao));
|
||||
}, [favorites, airport]);
|
||||
|
||||
function handleFavorite(value: boolean) {
|
||||
setIsFavorite(value);
|
||||
if (value) {
|
||||
addFavorite(airport.icao);
|
||||
} else {
|
||||
removeFavorite(airport.icao);
|
||||
}
|
||||
}
|
||||
|
||||
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