add user settings, add protected route

This commit is contained in:
NCanggoro 2023-10-09 16:24:45 +07:00
parent 68cc693e3f
commit 905ee3669c
11 changed files with 524 additions and 75 deletions

View File

@ -2,16 +2,17 @@ import { Route, Routes } from 'react-router-dom'
import { BrowserRouter as Router } from 'react-router-dom' import { BrowserRouter as Router } from 'react-router-dom'
import './app.css' import './app.css'
import { DefaultLayout } from './layouts' import { DefaultLayout } from './layouts'
import routes from './routes'
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import { Login, NotFound, Submissions } from './pages' import { Login, NotFound, Submissions } from './pages'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { persistore, store } from './store/config' import { persistore, store } from './store/config'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
import { ProtectedRoute } from './routes/ProtectedRoute' import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute'
import { getRoutes } from './routes';
export function App() { export function App() {
const { routes } = getRoutes();
return ( return (
<> <>
<Provider store={store}> <Provider store={store}>
@ -20,20 +21,51 @@ export function App() {
<Routes> <Routes>
<Route path='/login' element={<Login />} /> <Route path='/login' element={<Login />} />
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
{routes.map(({ path, name, element }) => ( {routes.map(({ path, name, element, protectedRoute }) => {
<> let Element = element as any
<Route if (protectedRoute === "user") {
path={path} return (
id={name} <>
element={element} <Route
/> path={path}
</> id={name}
))} element={
<Route path='/submissions' id='submissions' element={ <UserProtectedRoute>
<ProtectedRoute> <Element />
<Submissions /> </UserProtectedRoute>
</ProtectedRoute> }
} /> />
</>
)
}
if (protectedRoute === "admin") {
return (
<>
<>
<Route
path={path}
id={name}
element={
<AdminProtectedRoute>
<Element />
</AdminProtectedRoute>
}
/>
</>
</>
)
}
return (
<>
<Route
path={path}
id={name}
element={element}
/>
</>
)
})}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -9,6 +9,9 @@ const GET_REGENCIES = `${BASE_URL}/region/regencies`;
const GET_PROVINCES = `${BASE_URL}/region/provinces`; const GET_PROVINCES = `${BASE_URL}/region/provinces`;
const GET_CURRENT_USER_STATS = `${BASE_URL}/user/profile`; const GET_CURRENT_USER_STATS = `${BASE_URL}/user/profile`;
const PATCH_USER_AVATAR = `${BASE_URL}/user/avatar`;
const PATCH_USER_INFO = `${BASE_URL}/user`;
const DELETE_USER_AVATAR = `${BASE_URL}/user/avatar`
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`; const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings` const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`
@ -24,9 +27,10 @@ const GET_CURRENT_USER_REVIEW_LOCATION_URI = `${BASE_URL}/user/review/location`
export { export {
BASE_URL, BASE_URL,
SIGNUP_URI,
LOGIN_URI, LOGIN_URI,
LOGOUT_URI, LOGOUT_URI,
SIGNUP_URI,
DELETE_USER_AVATAR,
GET_REGIONS, GET_REGIONS,
GET_PROVINCES, GET_PROVINCES,
GET_REGENCIES, GET_REGENCIES,
@ -34,10 +38,12 @@ export {
GET_LIST_RECENT_LOCATIONS_RATING_URI, GET_LIST_RECENT_LOCATIONS_RATING_URI,
GET_LIST_TOP_LOCATIONS, GET_LIST_TOP_LOCATIONS,
GET_LIST_LOCATIONS_URI, GET_LIST_LOCATIONS_URI,
POST_CREATE_LOCATION,
GET_LOCATION_URI, GET_LOCATION_URI,
GET_LOCATION_TAGS_URI, GET_LOCATION_TAGS_URI,
GET_IMAGES_BY_LOCATION_URI, GET_IMAGES_BY_LOCATION_URI,
POST_REVIEW_LOCATION_URI,
GET_CURRENT_USER_REVIEW_LOCATION_URI, GET_CURRENT_USER_REVIEW_LOCATION_URI,
PATCH_USER_AVATAR,
PATCH_USER_INFO,
POST_REVIEW_LOCATION_URI,
POST_CREATE_LOCATION,
} }

View File

@ -1,9 +1,22 @@
import { NullValueRes } from "../types/common"; import { NullValueRes } from "../types/common";
export interface SocialMedia {
type: string,
value: string
}
export interface UserInfo {
about: string,
website: string,
social_media: Array<SocialMedia>
}
export type User = { export type User = {
id: number, id: number,
email: string, email: string,
username: string, username: string,
about: string,
website: string,
avatar_picture: string, avatar_picture: string,
banned_at: NullValueRes<"Time", string>, banned_at: NullValueRes<"Time", string>,
banned_until: NullValueRes<"Time", string>, banned_until: NullValueRes<"Time", string>,
@ -12,7 +25,7 @@ export type User = {
is_admin: boolean, is_admin: boolean,
is_critics: boolean, is_critics: boolean,
is_verfied: boolean, is_verfied: boolean,
social_media: NullValueRes<"RawMessage", any> social_media: Array<SocialMedia>
} }
@ -20,6 +33,8 @@ export function emptyUser(): User {
return { return {
avatar_picture: '', avatar_picture: '',
ban_reason: '', ban_reason: '',
about: '',
website: '',
banned_at: { Time: '', Valid: false}, banned_at: { Time: '', Valid: false},
banned_until: { Time: '', Valid: false}, banned_until: { Time: '', Valid: false},
email: '', email: '',
@ -28,7 +43,7 @@ export function emptyUser(): User {
is_critics: false, is_critics: false,
is_permaban: false, is_permaban: false,
is_verfied: false, is_verfied: false,
social_media: {RawMessage: '', Valid: false}, social_media: Array<SocialMedia>(),
username: '' username: ''
} }
} }

View File

@ -0,0 +1,286 @@
import { useSelector } from "react-redux";
import { DefaultButton, TitleSeparator, WarningButton } from "../../components";
import { UserRootState } from "../../store/type";
import { DEFAULT_AVATAR_IMG } from "../../constants/default";
import { ChangeEvent, TargetedEvent, useRef } from "preact/compat";
import { useState } from "react";
import { enumKeys, useAutosizeTextArea } from "../../utils";
import { SocialMediaEnum } from "../../types/common";
import { SocialMedia, UserInfo } from "../../../src/domains/User";
import './styles.css'
import { deleteUserAvatarService, patchUserAvatarService, patchUserInfoService } from "../../../src/services/users";
import { AxiosError } from "axios";
import { useDispatch } from "react-redux";
import { authAdded } from "../../features/auth/authSlice/authSlice";
interface PageState {
avatar_file: File | null,
avatar: string,
upload_file_err_msg: string,
file_input_key: string
}
function UserSettings() {
const user = useSelector((state: UserRootState) => state.auth)
const dispatch = useDispatch()
const [pageState, setPageState] = useState<PageState>({
avatar_file: null,
avatar: user.avatar_picture,
upload_file_err_msg: '',
file_input_key: Math.random().toString(24)
})
const [isLoading, setIsLoading] = useState(false)
const [form, setForm] = useState<UserInfo>({
about: user.about,
website: user.website,
social_media: user.social_media
})
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useAutosizeTextArea(textAreaRef.current, form.about);
function onChangeFormInput(e: ChangeEvent) {
let event = e.target as HTMLInputElement;
setForm({ ...form, [event.name]: event.value })
}
function handleTextAreaChange(e: ChangeEvent<HTMLTextAreaElement>): void {
const val = e.target as HTMLTextAreaElement;
setForm({ ...form, about: val.value })
}
function onAddSocialMediaClicked() {
let tempArr: Array<SocialMedia> = form.social_media
tempArr.push({ type: SocialMediaEnum.Facebook, value: '' })
setForm({ ...form, social_media: tempArr })
}
function onChangeSocialMediaInput(e: ChangeEvent, idx: number) {
let event = e.target as HTMLInputElement;
let socmed = form.social_media;
socmed[idx].value = event.value;
setForm({ ...form, social_media: socmed })
}
function onChangeSocialMediaType(e: ChangeEvent, idx: number) {
let event = e.target as HTMLOptionElement
let socmed = form.social_media;
socmed[idx].type = event.value;
setForm({ ...form, social_media: socmed })
}
function onDeleteSelectedSocial(idx: number) {
setForm({ ...form, social_media: form.social_media.filter((_, index) => index !== idx) })
}
async function onUploadAvatar(e: TargetedEvent) {
setIsLoading(true)
e.preventDefault();
try {
const form = new FormData();
form.append('file', pageState.avatar_file!)
const res = await patchUserAvatarService(form)
let userStore = {
...user,
avatar_picture: res.data.image_url
}
dispatch(authAdded(userStore));
setIsLoading(false)
} catch(err) {
let error = err as AxiosError;
console.log(error)
setIsLoading(false)
}
setPageState({ ...pageState, file_input_key: Math.random().toString(24)})
}
async function onRemoveAvatar(e: TargetedEvent) {
setIsLoading(true)
e.preventDefault();
try {
await deleteUserAvatarService()
let userStore = {
...user,
avatar_picture: ''
}
dispatch(authAdded(userStore))
setPageState({ ...pageState, avatar: ''})
setIsLoading(false)
} catch(err) {
setIsLoading(false)
const error = err as AxiosError
console.log(error)
}
}
async function onUpdateUserInfo(e: TargetedEvent) {
setIsLoading(true)
e.preventDefault();
try {
console.log(form)
const res = await patchUserInfoService(form)
let userData = {
...user,
about: res.data.about,
website: res.data.website,
social_media: res.data.social_media
}
dispatch(authAdded(userData))
setIsLoading(false)
}catch(err) {
setIsLoading(false)
let error = err as AxiosError;
alert(error)
}
}
function onChangeUploadImage(e: ChangeEvent) {
let event = e.target as HTMLInputElement;
const file = event.files![0] as File;
// if cancel button pressed
if (!file) {
return
}
if (file.type !== "image/png" && file.type !== "image/jpg" && file.type !== "image/jpeg" ) {
setPageState({ ...pageState, upload_file_err_msg: 'ONLY ACCEPT JPEG/PNG/JPG FILE!!!!' })
return
}
if (file.size > 10000000) {
setPageState({ ...pageState, upload_file_err_msg: 'file size too large, max file size 10mb' })
return
}
setPageState({ ...pageState, upload_file_err_msg: '', avatar_file: file, avatar: URL.createObjectURL(file)})
}
return (
<div className={'main-content content'}>
<div className={'mb-4'}>
<h1 className={'text-2xl'}>User settings</h1>
<TitleSeparator titleClasses="mt-8 " pageName={'avatar'} titleStyles={{ letterSpacing: .9 }} />
<div style={{ padding: '0 15px 20px 0', float: 'left', maxWidth: '50%', width: 200 }}>
<img
src={pageState.avatar !== '' ? pageState.avatar : DEFAULT_AVATAR_IMG}
style={{ aspectRatio: '1/1', objectFit: 'cover'}}
/>
</div>
<div className={'text-sm mb-5'} style={{ overflow: 'hidden' }}>
<span className={'mr-3'}>Change avatar</span>
<input name="uploadImage" key={pageState.file_input_key} type={"file"} accept={'image/*'} onChange={onChangeUploadImage} />
{pageState.upload_file_err_msg &&
<p className={'text-xs text-error mt-2'}>{pageState.upload_file_err_msg}</p>
}
<div className={'mt-2 text-tertiary'}>
<p>File extension must be .jpeg, .png, .jpg</p>
<p>File size max 10mb</p>
</div>
</div>
<div className={'text-xs button-avatar-container'}>
<DefaultButton
label="update"
className={isLoading ? 'pointer-events-none' : ''}
style={{ paddingLeft: 36, paddingRight: 36, textTransform: 'uppercase'}}
isLoading={isLoading}
onClick={onUploadAvatar}
/>
<WarningButton
label="remove avatar"
containerClassName="mt-5"
style={{ textTransform: 'uppercase', paddingLeft: 8, paddingRight: 8 }}
className={isLoading ? 'pointer-events-none' : ''}
onClick={onRemoveAvatar}
/>
</div>
<div style={{ clear: 'both' }} />
</div>
<section name="User Info">
<TitleSeparator pageName="INFO" />
<div className={'mb-3'} style={{ maxWidth: 600, minWidth: 280 }}>
<h2 className={'text-sm mb-1 font-bold'}>ABOUT YOU</h2>
<textarea
onChange={handleTextAreaChange}
maxLength={500}
ref={textAreaRef}
className={'p-2 text-sm bg-secondary'}
value={form.about}
style={{ border: 'none', overflow: 'auto', outline: 'none', boxShadow: 'none', width: '100%', minHeight: 100, overflowY: 'hidden' }}
/>
<div className={'mt-2 mb-1'}>
<h2 className={'font-bold text-sm mb-1'}>WEBSITE</h2>
<input className={'bg-secondary text-sm'} placeholder={'www.mywebsite.com'} style={{ width: '100%', borderRadius: 7, padding: '5px 10px' }} type={'text'} onChange={onChangeFormInput} name={'website'} value={form.website} />
</div>
<h2>Social</h2>
{form.social_media.map((val, index) => (
<div className={'mt-2'}>
<select
onChange={(e) => onChangeSocialMediaType(e, index)}
className={'bg-secondary inline-block text-sm'}
name="social_media_type"
id="social_media_type"
style={{ borderRadius: 7, padding: '5px 10px' }}
value={form.social_media[index].type.toLowerCase()}
>
{enumKeys(SocialMediaEnum).map(x => (
<option value={SocialMediaEnum[x]}>{SocialMediaEnum[x]}</option>
))
}
</select>
<input
className={'bg-secondary text-sm ml-1 mr-1 inline-block'}
style={{ width: '66%', borderRadius: 7, padding: '5px 10px' }}
type={'text'}
onChange={(e) => onChangeSocialMediaInput(e, index)}
name={'social_media'}
value={val.value} />
<span className={'text-xs text-gray mr-3'}>( username )</span>
<span
className={'text-sm bg-error'}
style={{ padding: '0 5px' }}
>
<a onClick={() => onDeleteSelectedSocial(index)} style={{ verticalAlign: 'text-bottom' }}>x</a>
</span>
</div>
))
}
<a onClick={onAddSocialMediaClicked}>
<div className={'text-center mt-2 bg-secondary'} style={{ padding: '5px 10px', borderRadius: 7 }}>
+ add social media
</div>
</a>
<div className={'flex flex-row justify-between mt-7'}>
<DefaultButton
label="update"
containerClassName="text-xs"
onClick={onUpdateUserInfo}
style={{ paddingLeft: 36, paddingRight: 36, textTransform: 'uppercase', }}
className={isLoading ? 'pointer-events-none' : ''}
isLoading={isLoading}
/>
<WarningButton
label="delete account"
containerClassName="text-xs"
style={{ textTransform: 'uppercase', paddingLeft: 8, paddingRight: 8 }}
isLoading={isLoading}
className={isLoading ? 'pointer-events-none' : ''}
/>
</div>
</div>
</section>
</div>
)
}
export default UserSettings;

View File

@ -0,0 +1,3 @@
.button-avatar-container p a:hover {
color: white;
}

View File

@ -10,6 +10,7 @@ import AddLocation from "./AddLocation";
import Submissions from "./Submissions"; import Submissions from "./Submissions";
import UserProfile from "./UserProfile"; import UserProfile from "./UserProfile";
import UserFeed from "./UserFeed"; import UserFeed from "./UserFeed";
import UserSettings from "./UserSettings";
export { export {
Login, Login,
@ -17,6 +18,7 @@ export {
Home, Home,
UserProfile, UserProfile,
UserFeed, UserFeed,
UserSettings,
NotFound, NotFound,

View File

@ -3,7 +3,7 @@ import { useSelector } from "react-redux"
import { UserRootState } from "src/store/type" import { UserRootState } from "src/store/type"
export const ProtectedRoute = ({children}: any) => { export const AdminProtectedRoute = ({children}: any) => {
const user = useSelector((state: UserRootState) => state.auth) const user = useSelector((state: UserRootState) => state.auth)
if(!user.is_admin) { if(!user.is_admin) {
@ -11,4 +11,13 @@ export const ProtectedRoute = ({children}: any) => {
} }
return children; return children;
}
export const UserProtectedRoute = ({ children }: any) => {
const user = useSelector((state: UserRootState) => state.auth)
if(Object.keys(user).length === 0) {
return <Navigate to={"/"} replace />
}
return children
} }

View File

@ -7,55 +7,92 @@ import {
Story, Story,
AddLocation, AddLocation,
UserProfile, UserProfile,
UserFeed UserFeed,
UserSettings,
Submissions
} from '../pages'; } from '../pages';
const routes = [ interface BaseRoutes {
{ path: string,
path: "/", name: string,
name: "Home", protectedRoute?: string,
element: <Home /> element: any
}, }
{
path: "/best-places",
name: "Home",
element: <BestLocation />
},
{
path: "/discover",
name: "Home",
element: <Discovery />
},
{
path: "/stories",
name: "Home",
element: <Story />
},
{
path: "/news-events",
name: "Home",
element: <NewsEvent />
},
{
path: "/location/:id",
name: "LocationDetail",
element: <LocationDetail />
},
{
path: "/add-location",
name: "AddLocation",
element: <AddLocation />
},
{
path: "/user/profile",
name: "UserProfile",
element: <UserProfile />
},
{
path: "/user/feed",
name: "UserFeed",
element: <UserFeed />
},
]
export default routes; export interface IRoutes {
routes: Array<BaseRoutes>
}
export const getRoutes = (): IRoutes => {
let groupRoutes: Array<BaseRoutes> = [
{
path: "/",
name: "Home",
element: <Home />
},
{
path: "/best-places",
name: "Home",
element: <BestLocation />
},
{
path: "/discover",
name: "Home",
element: <Discovery />
},
{
path: "/stories",
name: "Home",
element: <Story />
},
{
path: "/news-events",
name: "Home",
element: <NewsEvent />
},
{
path: "/location/:id",
name: "LocationDetail",
element: <LocationDetail />
},
// PROTECTED USER ROUTES
{
path: "/add-location",
name: "AddLocation",
protectedRoute: "user",
element: AddLocation
},
{
path: "/user/profile",
name: "UserProfile",
protectedRoute: "user",
element: UserProfile
},
{
path: "/user/settings",
name: "UserSettings",
protectedRoute: "user",
element: UserSettings
},
{
path: "/user/feed",
name: "UserFeed",
protectedRoute: "user",
element: UserFeed
},
//PROTECTED ADMIN ROUTES
{
path: "/admin/submissions",
name: "Submissions",
protectedRoute: "admin",
element: Submissions
},
]
let routes: IRoutes = {
routes: groupRoutes
}
return routes
}

View File

@ -16,7 +16,7 @@ interface IAuthentication {
async function createAccountService({ username, password }: IAuthentication) { async function createAccountService({ username, password }: IAuthentication) {
const newState = { ...initialState }; const newState = { ...initialState };
try { try {
const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password } }) const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true })
newState.data = response.data newState.data = response.data
newState.error = null newState.error = null
return newState return newState
@ -29,7 +29,7 @@ async function createAccountService({ username, password }: IAuthentication) {
async function loginService({ username, password }: IAuthentication) { async function loginService({ username, password }: IAuthentication) {
const newState = { ...initialState }; const newState = { ...initialState };
try { try {
const response = await client({ method: 'POST', url: LOGIN_URI, data: { username, password } }) const response = await client({ method: 'POST', url: LOGIN_URI, data: { username, password }, withCredentials: true })
newState.data = response.data newState.data = response.data
newState.error = null newState.error = null
return newState return newState

View File

@ -1,7 +1,8 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { GET_CURRENT_USER_STATS } from "../constants/api"; import { DELETE_USER_AVATAR, GET_CURRENT_USER_STATS, PATCH_USER_AVATAR, PATCH_USER_INFO } from "../constants/api";
import { IHttpResponse } from "../types/common"; import { IHttpResponse } from "../types/common";
import { client } from "./config"; import { client } from "./config";
import { UserInfo } from "../../src/domains/User";
async function getUserStatsService(): Promise<IHttpResponse> { async function getUserStatsService(): Promise<IHttpResponse> {
@ -19,6 +20,54 @@ async function getUserStatsService(): Promise<IHttpResponse> {
} }
} }
async function patchUserAvatarService(form: FormData): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try {
const res = await client({ method: "PATCH", url: PATCH_USER_AVATAR, data: form, withCredentials: true})
newState.data = res.data;
newState.status = res.status;
return newState;
} catch(error) {
let err = error as AxiosError;
newState.error = err
newState.status = err.status
throw(newState);
}
}
async function patchUserInfoService(data: UserInfo): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try {
const res = await client({ method: 'PATCH', url: PATCH_USER_INFO, data: data, withCredentials: true})
newState.data = res.data;
newState.status = res.status;
return newState;
} catch(error) {
let err = error as AxiosError;
newState.error = err;
newState.status = err.status;
throw(newState);
}
}
async function deleteUserAvatarService(): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try {
const res = await client({ method: 'DELETE', url: DELETE_USER_AVATAR, withCredentials: true})
newState.data = res.data;
newState.status = res.status
return newState
} catch (error) {
let err = error as AxiosError;
newState.error = err;
newState.status = err.status;
throw(newState);
}
}
export { export {
getUserStatsService, getUserStatsService,
patchUserAvatarService,
deleteUserAvatarService,
patchUserInfoService
} }

View File

@ -32,3 +32,13 @@ export enum LocationType {
HikingCamping = "hiking / camping", HikingCamping = "hiking / camping",
Other = "other" Other = "other"
} }
// https://www.similarweb.com/top-websites/indonesia/computers-electronics-and-technology/social-networks-and-online-communities/#:~:text=facebook.com%20ranked%20number%201,Media%20Networks%20websites%20in%20Indonesia.
// maybe should add ppgames.net too BASED
export enum SocialMediaEnum {
Facebook = "facebook",
Instagram = "instagram",
Tiktox = "tiktok",
X = "x",
Youtube = "youtube"
}