Compare commits
34 Commits
00c8831dc9
...
946bed296a
Author | SHA1 | Date | |
---|---|---|---|
946bed296a | |||
1c65b5b237 | |||
8eee747a3b | |||
7a137b3507 | |||
ac57a14b88 | |||
ca6a77e825 | |||
afaa18f75e | |||
d5e79272fb | |||
905ee3669c | |||
68cc693e3f | |||
781e9cdb79 | |||
25b9877f3a | |||
41cb93d39f | |||
79dc198df9 | |||
6a857254c2 | |||
e4e6f5fa86 | |||
76f28efb07 | |||
40b8b5f112 | |||
0b0c2c0db6 | |||
408ff41b7d | |||
f1b508685e | |||
4bc60a6dd3 | |||
cdb3fa2955 | |||
1fe205165d | |||
13391b18eb | |||
9fb697f4ef | |||
f4f5799590 | |||
c9d6118f0c | |||
edc856bb81 | |||
72a96f72f5 | |||
9c4fc6ddcd | |||
788610292e | |||
96185ab95a | |||
3980f11f18 |
@ -1 +1,7 @@
|
||||
yik hiling gis bir gik cipik mikirin kihidipin ying simintiri ini
|
||||
yik hiling gis bir gik cipik mikirin kihidipin ying simintiri ini
|
||||
|
||||
|
||||
|
||||
## RESOURCES
|
||||
- https://react-select.com/home (MAYBE USE THIS ONE OVER MY OWN DOGSHIT SELECT)
|
||||
- https://github.com/Upstatement/react-router-guards
|
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Preact + TS</title>
|
||||
<title>Hilingin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
@ -21,6 +21,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"yet-another-react-lightbox": "^3.12.2"
|
||||
|
11
src/app.css
11
src/app.css
@ -10,6 +10,17 @@
|
||||
border-color: #38444d;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
border: none;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background-color: #40444b;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
|
68
src/app.tsx
68
src/app.tsx
@ -2,24 +2,61 @@ import { Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
import './app.css'
|
||||
import { DefaultLayout } from './layouts'
|
||||
import routes from './routes'
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import { Login, NotFound } from './pages'
|
||||
import { Login, NotFound, Submissions } from './pages'
|
||||
import { Provider } from 'react-redux'
|
||||
import { persistore, store } from './store/config'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute'
|
||||
import { getRoutes } from './routes';
|
||||
|
||||
|
||||
export function App() {
|
||||
const { routes } = getRoutes();
|
||||
return (
|
||||
<>
|
||||
<Provider store={store}>
|
||||
<PersistGate persistor={persistore}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path='/login' element={<Login />} />
|
||||
<Route element={<DefaultLayout />}>
|
||||
{routes.map(({ path, name, element }) => (
|
||||
<Provider store={store}>
|
||||
<PersistGate persistor={persistore}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path='/login' element={<Login />} />
|
||||
<Route element={<DefaultLayout />}>
|
||||
{routes.map(({ path, name, element, protectedRoute }) => {
|
||||
let Element = element as any
|
||||
if (protectedRoute === "user") {
|
||||
return (
|
||||
<>
|
||||
<Route
|
||||
path={path}
|
||||
id={name}
|
||||
element={
|
||||
<UserProtectedRoute>
|
||||
<Element />
|
||||
</UserProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (protectedRoute === "admin") {
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<Route
|
||||
path={path}
|
||||
id={name}
|
||||
element={
|
||||
<AdminProtectedRoute>
|
||||
<Element />
|
||||
</AdminProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Route
|
||||
path={path}
|
||||
@ -27,13 +64,14 @@ export function App() {
|
||||
element={element}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</Route>
|
||||
)
|
||||
})}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
37
src/components/Button/DefaultButton/index.tsx
Normal file
37
src/components/Button/DefaultButton/index.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { TargetedEvent, CSSProperties } from "preact/compat";
|
||||
import './style.css';
|
||||
import { SpinnerLoading } from "../..";
|
||||
|
||||
interface DefaultButtonProps {
|
||||
label: string
|
||||
containerClassName?: string,
|
||||
containerStyle?: CSSProperties,
|
||||
className?: string,
|
||||
style?: CSSProperties
|
||||
onClick?: (event: TargetedEvent) => any,
|
||||
href?: string,
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const DefaultButton = (props: DefaultButtonProps) => (
|
||||
<div
|
||||
className={props.containerClassName}
|
||||
style={props.containerStyle}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={props.onClick}
|
||||
className={`bg-gray default-button ${props.className ? props.className : ''}`}
|
||||
style={{ paddingTop: 6, paddingBottom: 6, letterSpacing: .9, borderRadius: 7, ...props.style }}
|
||||
>
|
||||
{ props.isLoading ?
|
||||
<SpinnerLoading style={{ verticalAlign: 'middle'}}/>
|
||||
:
|
||||
props.label
|
||||
}
|
||||
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default DefaultButton;
|
4
src/components/Button/DefaultButton/style.css
Normal file
4
src/components/Button/DefaultButton/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
div a.default-button:hover {
|
||||
color: white;
|
||||
background-color: #575757;
|
||||
}
|
36
src/components/Button/WarningButton/index.tsx
Normal file
36
src/components/Button/WarningButton/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { CSSProperties, TargetedEvent } from "preact/compat";
|
||||
import './style.css';
|
||||
import { SpinnerLoading } from "../..";
|
||||
|
||||
interface DefaultButtonProps {
|
||||
label: string
|
||||
containerClassName?: string,
|
||||
containerStyle?: CSSProperties,
|
||||
className?: string,
|
||||
style?: CSSProperties,
|
||||
isLoading?: boolean,
|
||||
onClick?: (event: TargetedEvent) => any,
|
||||
href?: string,
|
||||
}
|
||||
|
||||
const WarningButton = (props: DefaultButtonProps) => (
|
||||
<div
|
||||
className={props.containerClassName}
|
||||
style={props.containerStyle}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={props.onClick}
|
||||
className={`bg-error warning-button ${props.className ? props.className : ''}`}
|
||||
style={{ paddingTop: 6, paddingBottom: 6, letterSpacing: .9, borderRadius: 7, ...props.style }}
|
||||
>
|
||||
{props.isLoading ?
|
||||
<SpinnerLoading style={{ verticalAlign: 'middle'}}/>
|
||||
:
|
||||
props.label
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default WarningButton;
|
4
src/components/Button/WarningButton/style.css
Normal file
4
src/components/Button/WarningButton/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
div a.warning-button:hover{
|
||||
color: white;
|
||||
background-color: #af3030;
|
||||
}
|
128
src/components/DropdownInput/index.tsx
Normal file
128
src/components/DropdownInput/index.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import "./style.css";
|
||||
|
||||
const Icon = () => {
|
||||
return (
|
||||
<svg height="20" width="20" viewBox="0 0 20 20" fill={"white"}>
|
||||
<path d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface dropdownProps {
|
||||
placeholder: string,
|
||||
options: Array<any>,
|
||||
labelPropsName: string,
|
||||
isSearchable: boolean,
|
||||
onChange: Function
|
||||
}
|
||||
|
||||
const DropdownInput = ({
|
||||
placeholder,
|
||||
options,
|
||||
isSearchable,
|
||||
labelPropsName,
|
||||
onChange
|
||||
}: dropdownProps) => {
|
||||
const [showMenu, setShowMenu] = useState<boolean>(false);
|
||||
const [selectedValue, setSelectedValue] = useState<any>();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const searchRef = useRef<any>();
|
||||
const inputRef = useRef<any>();
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue("");
|
||||
if (showMenu && searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
}
|
||||
}, [showMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: any) => {
|
||||
if (inputRef.current && !inputRef.current.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handler);
|
||||
return () => {
|
||||
window.removeEventListener("click", handler);
|
||||
};
|
||||
});
|
||||
const handleInputClick = () => {
|
||||
setShowMenu(!showMenu);
|
||||
};
|
||||
|
||||
const getDisplay = () => {
|
||||
if (!selectedValue) {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
return selectedValue[labelPropsName]
|
||||
};
|
||||
|
||||
const onItemClick = (option: any) => {
|
||||
setSelectedValue(option)
|
||||
onChange(option);
|
||||
};
|
||||
|
||||
const isSelected = (option: any) => {
|
||||
|
||||
if (!selectedValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedValue[labelPropsName] === option[labelPropsName];
|
||||
};
|
||||
|
||||
const onSearch = (e: ChangeEvent) => {
|
||||
const event = e.target as HTMLInputElement
|
||||
setSearchValue(event.value);
|
||||
};
|
||||
|
||||
const getIDropdownInputPropss = () => {
|
||||
if (!searchValue) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter(
|
||||
(option: any) =>
|
||||
option[labelPropsName].toLowerCase().indexOf(searchValue.toLowerCase()) >= 0
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dropdown-container">
|
||||
<div ref={inputRef} onClick={handleInputClick} className="dropdown-input">
|
||||
<div className="dropdown-selected-value text-sm">{getDisplay()}</div>
|
||||
<div className="dropdown-tools">
|
||||
<div className="dropdown-tool">
|
||||
<Icon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<div className="dropdown-item-menu">
|
||||
{isSearchable && (
|
||||
<div className="search-box bg-secondary">
|
||||
<input className={'bg-primary text-sm'} onChange={onSearch} value={searchValue} ref={searchRef} />
|
||||
</div>
|
||||
)}
|
||||
<div class={'dropdown-item-container'}>
|
||||
{getIDropdownInputPropss().map((option: any) => (
|
||||
<div
|
||||
onClick={() => onItemClick(option)}
|
||||
key={option[labelPropsName]}
|
||||
className={`dropdown-item text-sm ${isSelected(option) && "selected"}`}
|
||||
>
|
||||
{option[labelPropsName]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownInput;
|
104
src/components/DropdownInput/style.css
Normal file
104
src/components/DropdownInput/style.css
Normal file
@ -0,0 +1,104 @@
|
||||
.dropdown-container {
|
||||
text-align: left;
|
||||
border: 1px solid #474747;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
min-width: 325px;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-input {
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropdown-input-menu {
|
||||
display: block;
|
||||
position: absolute;
|
||||
transform: translateY(4px);
|
||||
width: 100%;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
overflow: auto;
|
||||
max-height: 150px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-item-container {
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/* width */
|
||||
.dropdown-item-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.dropdown-item-container::-webkit-scrollbar-track {
|
||||
background: #212121;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
.dropdown-item-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
.dropdown-item-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #9fc3f870;
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
background-color: #a8adb3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-selected-value {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.dropdown-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dropdown-tag-item {
|
||||
background-color: #ddd;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-tag-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 5px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import React, { TargetedEvent } from "preact/compat";
|
||||
import React from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { UserRootState } from "../../store/type";
|
||||
@ -22,7 +22,7 @@ function Header() {
|
||||
setSearchVal(val.value)
|
||||
}
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
try {
|
||||
await logoutService()
|
||||
dispatch(logout())
|
||||
@ -60,18 +60,19 @@ function Header() {
|
||||
src={'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
|
||||
/>
|
||||
</a>
|
||||
{user.username &&
|
||||
<div className={'profile-dropdown-img bg-secondary text-left'} style={pageState.profileMenu ? { display: 'block'} : { display: 'none'}}>
|
||||
<a href={'#'}><div className={'p-2'}>Profile</div></a>
|
||||
<a href={'#'}><div className={'p-2'}>Feed</div></a>
|
||||
<a href={'#'}><div className={'p-2'}>Add location</div></a>
|
||||
<a href={'#'} onClick={handleLogout}><div className={'p-2'}>Logout</div></a>
|
||||
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
|
||||
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
|
||||
</div>
|
||||
{user.username &&
|
||||
<div className={'profile-dropdown-img bg-secondary text-left'} style={pageState.profileMenu ? { display: 'block' } : { display: 'none' }}>
|
||||
<a href={'/user/profile'}><div className={'p-2'}>Profile</div></a>
|
||||
<a href={'#'}><div className={'p-2'}>Feed</div></a>
|
||||
<a href={'/add-location'}><div className={'p-2'}>Add location</div></a>
|
||||
<a href={'/add-location'}><div className={'p-2'}>Settings</div></a>
|
||||
<a href={'#'} onClick={handleLogout}><div className={'p-2'}>Logout</div></a>
|
||||
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
|
||||
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<form onSubmit={onSearchSubmit} className={`search-input ${dropdown ? "search-input-dropdown" : ""}`}>
|
||||
<form onSubmit={onSearchSubmit} className={`header-search-input ${dropdown ? "search-input-dropdown" : ""}`}>
|
||||
<label>
|
||||
<input
|
||||
type="text"
|
||||
@ -98,10 +99,14 @@ function Header() {
|
||||
<div className={'profile-container'}>
|
||||
<a href={user.username ? '#' : '/login'} onClick={() => user.username ? setPageState({ ...pageState, profileMenu: !pageState.profileMenu }) : ''} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>{user.username ? user.username : 'Sign in'}</a>
|
||||
{user && screen.width > 600 &&
|
||||
<div className={'profile-dropdown bg-secondary ml-6'} style={pageState.profileMenu ? { display: 'block' } : {display: 'none'}}>
|
||||
<a href={'#'}><div className={'p-1'}>Profile</div></a>
|
||||
<div className={'profile-dropdown bg-secondary ml-6'} style={pageState.profileMenu ? { display: 'block' } : { display: 'none' }}>
|
||||
<a href={'/add-location'}><div className={'p-1'}>Add location</div></a>
|
||||
<a href={'/user/profile'}><div className={'p-1'}>Profile</div></a>
|
||||
<a href={'#'}><div className={'p-1'}>Feed</div></a>
|
||||
<a href={'#'}><div className={'p-1'}>Add location</div></a>
|
||||
<a href={'/user/settings'}><div className={'p-1'}>Settings</div></a>
|
||||
{user.is_admin &&
|
||||
<a href={'/submissions'} ><div className={'p-1'}>Submissions</div></a>
|
||||
}
|
||||
<a href={'#'} onClick={handleLogout}><div className={'p-1'}>Logout</div></a>
|
||||
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
|
||||
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
|
||||
|
@ -3,11 +3,11 @@
|
||||
}
|
||||
|
||||
|
||||
label {
|
||||
.header-search-input label {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label:before {
|
||||
.header-search-input label:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
@ -41,6 +41,7 @@ label:before {
|
||||
padding: 5px;
|
||||
width: 135px;
|
||||
font-size: 13px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.profile-dropdown a div:hover {
|
||||
@ -76,7 +77,7 @@ label:before {
|
||||
text-align: 'center'; */
|
||||
}
|
||||
|
||||
.search-input {
|
||||
.header-search-input {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@ -124,7 +125,7 @@ label:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form.search-input {
|
||||
form.header-search-input {
|
||||
display: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { CSSProperties } from 'preact/compat'
|
||||
import './style.css'
|
||||
export default function SpinnerLoading() {
|
||||
|
||||
interface SpinnerLoadingProps {
|
||||
style?: CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SpinnerLoading(props: SpinnerLoadingProps) {
|
||||
return (
|
||||
<div className={'spinner'} style={{ display: 'inline-block'}}></div>
|
||||
<div className={`spinner ${props.className ? props.className : ''}`} style={{ display: 'inline-block', ...props.style}}></div>
|
||||
)
|
||||
}
|
37
src/components/RefOutsideClick/index.tsx
Normal file
37
src/components/RefOutsideClick/index.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useRef, useEffect } from "preact/compat";
|
||||
|
||||
interface RefOutsideClickInterface {
|
||||
onOutsideClick: Function,
|
||||
children: any
|
||||
}
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/32553158/detect-click-outside-react-component
|
||||
*/
|
||||
function useRefOutsideClick(ref: any, onOutsideClick: Function) {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
onOutsideClick()
|
||||
}
|
||||
}
|
||||
// Bind the event listener
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
// Unbind the event listener on clean up
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that alerts if you click outside of it
|
||||
*/
|
||||
function RefOutsideClick(props: RefOutsideClickInterface) {
|
||||
const wrapperRef = useRef(null);
|
||||
useRefOutsideClick(wrapperRef, props.onOutsideClick);
|
||||
|
||||
return <div ref={wrapperRef}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export default RefOutsideClick;
|
27
src/components/Separator/NavigationSeparator/index.tsx
Normal file
27
src/components/Separator/NavigationSeparator/index.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { CSSProperties, TargetedEvent } from 'preact/compat';
|
||||
import './style.css';
|
||||
|
||||
type NavigationSeparatorProps = {
|
||||
pageNames: Array<string>,
|
||||
onClick: (e: TargetedEvent, name: string) => void,
|
||||
titleStyles?: any,
|
||||
containerStyle?: CSSProperties
|
||||
selectedValue?: string | number
|
||||
}
|
||||
|
||||
function NavigationSeparator(props: NavigationSeparatorProps) {
|
||||
return (
|
||||
<div class={"divider mb-2 navigation-separator-container"} style={props.containerStyle}>
|
||||
{props.pageNames.map(x => (
|
||||
<a
|
||||
className={`text-xs font-bold uppercase mr-5 ${props.selectedValue != x.toLowerCase() ? 'text-gray' : 'pointer-events-none'}`}
|
||||
style={{...props.titleStyles, letterSpacing: .9}}
|
||||
onClick={(e) => props.onClick(e, x)}>
|
||||
{x}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationSeparator;
|
3
src/components/Separator/NavigationSeparator/style.css
Normal file
3
src/components/Separator/NavigationSeparator/style.css
Normal file
@ -0,0 +1,3 @@
|
||||
.navigation-separator-text:hover {
|
||||
color: white
|
||||
}
|
17
src/components/Separator/TitleSeparator/index.tsx
Normal file
17
src/components/Separator/TitleSeparator/index.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
type SeparatorProps = {
|
||||
pageName: string,
|
||||
titleStyles?: any,
|
||||
titleClasses?: string
|
||||
}
|
||||
|
||||
function TitleSeparator(props: SeparatorProps) {
|
||||
return (
|
||||
<div class={"flex flex-row justify-between divider mb-2"}>
|
||||
<h2 className={`text-sm font-bold uppercase ${props.titleClasses}`} style={props.titleStyles}>
|
||||
{props.pageName}
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleSeparator;
|
@ -4,12 +4,13 @@ type SeparatorProps = {
|
||||
pageName: String,
|
||||
pageLink: string,
|
||||
secondLink?: string,
|
||||
titleStyles?: any
|
||||
}
|
||||
|
||||
function SeparatorWithAnchor(props: SeparatorProps) {
|
||||
return (
|
||||
<div class={"flex flex-row justify-between divider mb-2"}>
|
||||
<h1 className="text-sm font-bold" style={{ textTransform: 'uppercase' }}>
|
||||
<h1 className="text-sm font-bold uppercase" style={props.titleStyles}>
|
||||
<a href={props.pageLink}>{props.pageName}</a>
|
||||
</h1>
|
||||
{props.secondLink &&
|
||||
|
@ -1,17 +1,34 @@
|
||||
import Header from "./Header";
|
||||
|
||||
import RefOutsideClick from "./RefOutsideClick";
|
||||
|
||||
import WarningButton from "./Button/WarningButton";
|
||||
import DefaultButton from "./Button/DefaultButton";
|
||||
|
||||
import SeparatorWithAnchor from "./Separator/WithAnchor";
|
||||
import DefaultSeparator from "./Separator/Default";
|
||||
import TitleSeparator from "./Separator/TitleSeparator";
|
||||
import NavigationSeparator from "./Separator/NavigationSeparator";
|
||||
import Footer from './Footer/';
|
||||
import CustomInterweave from "./CustomInterweave";
|
||||
import DropdownInput from "./DropdownInput";
|
||||
|
||||
import SpinnerLoading from "./Loading/Spinner";
|
||||
|
||||
export {
|
||||
Header,
|
||||
WarningButton,
|
||||
DefaultButton,
|
||||
|
||||
RefOutsideClick,
|
||||
|
||||
SeparatorWithAnchor,
|
||||
DefaultSeparator,
|
||||
TitleSeparator,
|
||||
NavigationSeparator,
|
||||
Footer,
|
||||
CustomInterweave,
|
||||
DropdownInput,
|
||||
|
||||
SpinnerLoading,
|
||||
}
|
@ -1,29 +1,54 @@
|
||||
const BASE_URL = "http://localhost:8888"
|
||||
|
||||
const SIGNUP_URI = `${BASE_URL}/user/signup`
|
||||
const LOGIN_URI = `${BASE_URL}/user/login`
|
||||
const LOGOUT_URI = `${BASE_URL}/user/logout`
|
||||
const SIGNUP_URI = `${BASE_URL}/user/signup`
|
||||
const LOGIN_URI = `${BASE_URL}/user/login`
|
||||
const LOGOUT_URI = `${BASE_URL}/user/logout`
|
||||
|
||||
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
|
||||
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`
|
||||
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`
|
||||
const GET_LOCATION_URI = `${BASE_URL}/location`;
|
||||
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`
|
||||
const GET_REGIONS = `${BASE_URL}/regions`;
|
||||
const GET_REGENCIES = `${BASE_URL}/region/regencies`;
|
||||
const GET_PROVINCES = `${BASE_URL}/region/provinces`;
|
||||
|
||||
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`
|
||||
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 POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`
|
||||
const GET_NEWS_EVENTS_URI = `${BASE_URL}/news-events`;
|
||||
const POST_NEWS_EVENTS_URI = GET_NEWS_EVENTS_URI
|
||||
|
||||
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
|
||||
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`
|
||||
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`
|
||||
const GET_LOCATION_URI = `${BASE_URL}/location`;
|
||||
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`
|
||||
const POST_CREATE_LOCATION = GET_LIST_LOCATIONS_URI;
|
||||
|
||||
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`
|
||||
|
||||
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`
|
||||
const GET_CURRENT_USER_REVIEW_LOCATION_URI = `${BASE_URL}/user/review/location`
|
||||
|
||||
export {
|
||||
BASE_URL,
|
||||
SIGNUP_URI,
|
||||
LOGIN_URI,
|
||||
LOGOUT_URI,
|
||||
SIGNUP_URI,
|
||||
DELETE_USER_AVATAR,
|
||||
GET_NEWS_EVENTS_URI,
|
||||
GET_REGIONS,
|
||||
GET_PROVINCES,
|
||||
GET_REGENCIES,
|
||||
GET_CURRENT_USER_STATS,
|
||||
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
||||
GET_LIST_TOP_LOCATIONS,
|
||||
GET_LIST_LOCATIONS_URI,
|
||||
GET_LOCATION_URI,
|
||||
GET_LOCATION_TAGS_URI,
|
||||
GET_IMAGES_BY_LOCATION_URI,
|
||||
POST_REVIEW_LOCATION_URI
|
||||
GET_CURRENT_USER_REVIEW_LOCATION_URI,
|
||||
PATCH_USER_AVATAR,
|
||||
PATCH_USER_INFO,
|
||||
POST_REVIEW_LOCATION_URI,
|
||||
POST_CREATE_LOCATION,
|
||||
POST_NEWS_EVENTS_URI,
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export const DEFAULT_AVATAR_IMG = 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png';
|
||||
export const DEFAULT_AVATAR_IMG = 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png';
|
||||
export const DEFAULT_LOCATION_THUMBNAIL_IMG = 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg';
|
||||
|
||||
|
13
src/domains/LocationInfo.ts
Normal file
13
src/domains/LocationInfo.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NullValueRes } from '../types/common';
|
||||
|
||||
export type LocationInfo = {
|
||||
id: Number,
|
||||
name: string,
|
||||
thumbnail: NullValueRes<'String', string>,
|
||||
regency_name: String,
|
||||
province_name: String,
|
||||
critic_score: Number,
|
||||
critic_count: Number,
|
||||
user_score: Number,
|
||||
user_count: Number
|
||||
}
|
29
src/domains/NewsEvent.ts
Normal file
29
src/domains/NewsEvent.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export type News = {
|
||||
id: number,
|
||||
title: string,
|
||||
url: string
|
||||
source: string
|
||||
thumbnail: string,
|
||||
description: string
|
||||
is_deleted: boolean,
|
||||
submitted_by: string,
|
||||
approved_by: number,
|
||||
created_at: string,
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export function emptyNewsEvent(): News {
|
||||
return {
|
||||
id: 0,
|
||||
title: '',
|
||||
url: '',
|
||||
source: '',
|
||||
thumbnail: '',
|
||||
description: '',
|
||||
is_deleted: false,
|
||||
submitted_by: '',
|
||||
approved_by: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
}
|
13
src/domains/Province.ts
Normal file
13
src/domains/Province.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type Province = {
|
||||
id: number,
|
||||
province_name: string,
|
||||
region_id: number
|
||||
}
|
||||
|
||||
export function emptyProvince(): Province {
|
||||
return {
|
||||
id: 0,
|
||||
province_name: '',
|
||||
region_id: 0
|
||||
}
|
||||
}
|
13
src/domains/Regency.ts
Normal file
13
src/domains/Regency.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type Regency = {
|
||||
id: number,
|
||||
regency_name: string,
|
||||
province_id: number
|
||||
}
|
||||
|
||||
export function emptyRegency(): Regency {
|
||||
return {
|
||||
id: 0,
|
||||
province_id: 0,
|
||||
regency_name: ''
|
||||
}
|
||||
}
|
4
src/domains/Region.ts
Normal file
4
src/domains/Region.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type Region = {
|
||||
id: number,
|
||||
region_name: string
|
||||
}
|
49
src/domains/User.ts
Normal file
49
src/domains/User.ts
Normal file
@ -0,0 +1,49 @@
|
||||
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 = {
|
||||
id: number,
|
||||
email: string,
|
||||
username: string,
|
||||
about: string,
|
||||
website: string,
|
||||
avatar_picture: string,
|
||||
banned_at: NullValueRes<"Time", string>,
|
||||
banned_until: NullValueRes<"Time", string>,
|
||||
ban_reason: string,
|
||||
is_permaban: boolean,
|
||||
is_admin: boolean,
|
||||
is_critics: boolean,
|
||||
is_verfied: boolean,
|
||||
social_media: Array<SocialMedia>
|
||||
}
|
||||
|
||||
|
||||
export function emptyUser(): User {
|
||||
return {
|
||||
avatar_picture: '',
|
||||
ban_reason: '',
|
||||
about: '',
|
||||
website: '',
|
||||
banned_at: { Time: '', Valid: false},
|
||||
banned_until: { Time: '', Valid: false},
|
||||
email: '',
|
||||
id: 0,
|
||||
is_admin: false,
|
||||
is_critics: false,
|
||||
is_permaban: false,
|
||||
is_verfied: false,
|
||||
social_media: Array<SocialMedia>(),
|
||||
username: ''
|
||||
}
|
||||
}
|
18
src/domains/index.ts
Normal file
18
src/domains/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { LocationInfo } from "./LocationInfo"
|
||||
import { Province, emptyProvince } from "./Province"
|
||||
import { Regency, emptyRegency } from "./Regency"
|
||||
import { User } from './User';
|
||||
import { Region } from "./Region"
|
||||
|
||||
export type {
|
||||
LocationInfo,
|
||||
Province,
|
||||
Regency,
|
||||
Region,
|
||||
User,
|
||||
}
|
||||
|
||||
export {
|
||||
emptyProvince,
|
||||
emptyRegency
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
export interface IUser {
|
||||
id: Number,
|
||||
email: String,
|
||||
username: String,
|
||||
avatar_picture: String,
|
||||
banned_at: NullValueRes<"Time", String>,
|
||||
banned_until: NullValueRes<"Time", String>,
|
||||
ban_reason: String,
|
||||
is_permaban: boolean,
|
||||
is_admin: boolean,
|
||||
is_critics: boolean,
|
||||
is_verfied: boolean,
|
||||
social_media: NullValueRes<"RawMessage", any>
|
||||
}
|
243
src/pages/AddLocation/index.tsx
Normal file
243
src/pages/AddLocation/index.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import { ChangeEvent, TargetedEvent, useEffect, useState } from "preact/compat";
|
||||
import { getListRecentLocationsRatingsService, getProvincesService, getRegenciesService } from "../../services";
|
||||
import { LocationInfo } from "../../domains/LocationInfo";
|
||||
import { DropdownInput } from "../../components";
|
||||
import { IndonesiaRegionsInfo, LocationType } from "../../types/common";
|
||||
import { Regency, emptyRegency } from "../../domains";
|
||||
import { Form } from './types';
|
||||
import { enumKeys } from "../../utils";
|
||||
import './style.css';
|
||||
import { createLocationService } from "../../services/locations";
|
||||
import { useSelector } from "react-redux";
|
||||
import { UserRootState } from "../../store/type";
|
||||
|
||||
function AddLocation() {
|
||||
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>()
|
||||
const [idnRegions, setIdnRegions] = useState<IndonesiaRegionsInfo>({
|
||||
regencies: Array<Regency>(),
|
||||
})
|
||||
const [form, setForm] = useState<Form>({
|
||||
name: '',
|
||||
address: '',
|
||||
google_maps_link: '',
|
||||
location_type: LocationType.Beach,
|
||||
regency: emptyRegency(),
|
||||
thumbnails: [],
|
||||
})
|
||||
|
||||
const user = useSelector((state: UserRootState) => state.auth)
|
||||
|
||||
const [pageState, setPageState] = useState({
|
||||
regency_form_error: false,
|
||||
})
|
||||
|
||||
async function getRecentLocations() {
|
||||
try {
|
||||
const locations = await getListRecentLocationsRatingsService(9)
|
||||
setRecentLocations(locations.data)
|
||||
// setIsLoading(false)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getRegions() {
|
||||
try {
|
||||
const provinces = await getProvincesService();
|
||||
const regencies = await getRegenciesService();
|
||||
|
||||
setIdnRegions({
|
||||
provinces: provinces.data,
|
||||
regencies: regencies.data
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function onSubmitForm(e: TargetedEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if(form.regency.regency_name === '') {
|
||||
setPageState({ regency_form_error: true })
|
||||
return
|
||||
}
|
||||
|
||||
let tempThumbnailArr: Array<File> = [];
|
||||
let formData = new FormData();
|
||||
|
||||
form.thumbnails.forEach(x => {
|
||||
tempThumbnailArr.push(x.file)
|
||||
})
|
||||
|
||||
formData.append("address", form.address);
|
||||
formData.append("location_type", form.location_type);
|
||||
formData.append("name", form.name);
|
||||
formData.append("regency_id", form.regency.id.toString());
|
||||
formData.append("submitted_by", user.id.toString());
|
||||
formData.append("google_maps_link", form.google_maps_link);
|
||||
|
||||
for(let i = 0; i < tempThumbnailArr.length; i++) {
|
||||
formData.append("thumbnail", tempThumbnailArr[i])
|
||||
}
|
||||
|
||||
try {
|
||||
await createLocationService(formData)
|
||||
alert("Location Added")
|
||||
|
||||
} catch(error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeUploadImage(e: TargetedEvent) {
|
||||
e.preventDefault()
|
||||
let event = e.target as HTMLInputElement;
|
||||
const files = Array.from(event.files as ArrayLike<File>);
|
||||
|
||||
const result = files.filter((x) => {
|
||||
if (x.type === "image/jpg" || x.type === "image/png" || x.type === "image/jpeg") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}).map(v => {
|
||||
let ret = {
|
||||
file: v,
|
||||
url: URL.createObjectURL(v)
|
||||
}
|
||||
return ret
|
||||
})
|
||||
|
||||
setForm({ ...form, thumbnails: result })
|
||||
}
|
||||
|
||||
function onDeleteSelectedThumbnail(e: TargetedEvent, idx: number) {
|
||||
e.preventDefault();
|
||||
|
||||
// remove image from FileList
|
||||
const dt = new DataTransfer();
|
||||
const tempInput = document.getElementById('imageUpload')!;
|
||||
const input = tempInput as HTMLInputElement;
|
||||
const { files } = input;
|
||||
|
||||
for (let i = 0; i < files!.length; i++) {
|
||||
const file = files![i]
|
||||
if (idx !== i)
|
||||
dt.items.add(file)
|
||||
}
|
||||
|
||||
input.files = dt.files
|
||||
|
||||
let thumbnails = form.thumbnails.filter((_, index) => index != idx);
|
||||
setForm({ ...form, thumbnails: thumbnails })
|
||||
}
|
||||
|
||||
function onChangeFormInput(e: ChangeEvent) {
|
||||
let event = e.target as HTMLInputElement;
|
||||
setForm({ ...form, [event.name]: event.value })
|
||||
}
|
||||
|
||||
function onDeleteAllThumbnails(e: TargetedEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const dt = new DataTransfer();
|
||||
const tempInput = document.getElementById('imageUpload');
|
||||
const input = tempInput as HTMLInputElement;
|
||||
|
||||
dt.items.clear();
|
||||
input.files = dt.files
|
||||
|
||||
setForm({ ...form, thumbnails: [] })
|
||||
}
|
||||
|
||||
function onChangeRegencyDropdownInput(val: Regency) {
|
||||
setPageState({ ...pageState, regency_form_error: false })
|
||||
setForm({ ...form, regency: val })
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getRecentLocations()
|
||||
getRegions()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={'content main-content'}>
|
||||
<div style={{ tableLayout: 'fixed', display: 'table', width: '100%' }}>
|
||||
<div style={{ textAlign: 'left', padding: '20px 30px', maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%' }}>
|
||||
<h1 className={'text-3xl mb-5 font-bold'}>Add New Location</h1>
|
||||
<div style={{ backgroundColor: '#2f3136', padding: 10, margin: '0 0 25px' }}>
|
||||
<form onSubmit={onSubmitForm}>
|
||||
<span className={'block mt-2 text-sm mb-2'}>Location Name <span className={'text-error'}>*</span></span>
|
||||
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'name'} value={form.name} required />
|
||||
<span className={'block mt-2 text-sm mb-2'}>Address <span className={'text-error'}>*</span></span>
|
||||
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'address'} value={form.address} required />
|
||||
<label className={'block text-sm mt-2 mb-2'} for="location_type">Location Category</label>
|
||||
<select className={'bg-primary p-1'} name="location_type" id="location_type">
|
||||
{ enumKeys(LocationType).map(x => (
|
||||
<option value={LocationType[x]}>{LocationType[x]}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<span className={'block mt-2 text-sm mb-2'}>Kota / Kabupaten <span className={'text-error'}>*</span> <span className={`text-xs text-error ${!pageState.regency_form_error && 'hidden'}`}> (regency mustn't be empty)</span></span>
|
||||
<DropdownInput
|
||||
isSearchable={true}
|
||||
onChange={(val: Regency) => onChangeRegencyDropdownInput(val)}
|
||||
labelPropsName={"regency_name"}
|
||||
options={idnRegions.regencies!}
|
||||
placeholder={''}
|
||||
/>
|
||||
<span className={'block mt-2 text-sm mb-2'}>Google Maps Link <span className={'text-error'}>*</span></span>
|
||||
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'google_maps_link'} value={form.google_maps_link} required />
|
||||
<span className={'block mt-2 text-sm mb-2'}>Thumbnails</span>
|
||||
<input id={'imageUpload'} className={'inputfile'} type="file" accept={'image/*'} multiple onChange={onChangeUploadImage} />
|
||||
<label for={'imageUpload'}>Choose File(s)</label>
|
||||
<div style={{ clear: 'both' }} />
|
||||
{form.thumbnails.length > 0 &&
|
||||
form.thumbnails.map((x, idx) => (
|
||||
<div className={'mr-3 font-bold'} style={{ width: 150, display: 'inline-block', textAlign: 'right', fontSize: 20 }}>
|
||||
<a onClick={(e) => onDeleteSelectedThumbnail(e, idx)}>
|
||||
x
|
||||
</a>
|
||||
<img
|
||||
loading={'lazy'}
|
||||
src={x.url}
|
||||
className={'mt-1'}
|
||||
style={{ aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{form.thumbnails.length > 0 &&
|
||||
<a className={'block mt-2'} onClick={onDeleteAllThumbnails}>Delete All Thumbnails</a>
|
||||
}
|
||||
|
||||
<input type={'submit'} value={'Submit'} className={'block p-1 text-sm text-primary mt-4'} style={{ backgroundColor: '#a8adb3', letterSpacing: .5, width: 75 }} />
|
||||
</form>
|
||||
<span className={'mt-5 text-sm font-bold block'}>NOTE: LOCATION SUBMISSION MAY BE EDITED BY MODERATOR SO DON'T PUT STUPID ASS THUMBNAILS YOU 1 CENT DOORKNOB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 345, textAlign: 'left', padding: '15px 0' }}>
|
||||
<p>Recently added locations</p>
|
||||
{recentLocations?.map(x => (
|
||||
<div style={{ width: '32%', display: 'inline-block', padding: '1px 1%' }}>
|
||||
<a href={'#'} title={x.name}>
|
||||
<img
|
||||
loading={'lazy'}
|
||||
src={x.thumbnail.String.toString()}
|
||||
alt={x.name}
|
||||
style={{ aspectRatio: '1/1' }}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddLocation;
|
33
src/pages/AddLocation/style.css
Normal file
33
src/pages/AddLocation/style.css
Normal file
@ -0,0 +1,33 @@
|
||||
.input-text {
|
||||
border-radius: 7px;
|
||||
min-width: 325px;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.inputfile {
|
||||
width: 0.1px;
|
||||
height: 0.1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.inputfile + label {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
background-color: #555555;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.inputfile + label:hover {
|
||||
background-color: #202225;
|
||||
}
|
||||
|
||||
.inputfile + label {
|
||||
cursor: pointer; /* "hand" cursor */
|
||||
}
|
16
src/pages/AddLocation/types.ts
Normal file
16
src/pages/AddLocation/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { LocationType } from "src/types/common"
|
||||
import { Regency } from "../../domains"
|
||||
|
||||
export interface Thumbnail {
|
||||
file: File,
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Form {
|
||||
name: string,
|
||||
address: string,
|
||||
regency: Regency,
|
||||
location_type: LocationType,
|
||||
google_maps_link: string,
|
||||
thumbnails: Array<Thumbnail>
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import { TargetedEvent } from "preact/compat";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getListTopLocationsService } from "../../services";
|
||||
import { DefaultSeparator } from "../../components";
|
||||
import { TargetedEvent } from "preact/compat";
|
||||
import { NullValueRes } from "../../types/common";
|
||||
import './style.css';
|
||||
|
||||
interface TopLocation {
|
||||
@ -57,7 +58,11 @@ function BestLocation() {
|
||||
|
||||
async function getTopLocations() {
|
||||
try {
|
||||
const res = await getListTopLocationsService({ page: page, page_size: 20, order_by: pageState.filterScoreTypeidx, region_type: pageState.filterRegionType })
|
||||
const res = await getListTopLocationsService({
|
||||
page: page, page_size: 20,
|
||||
order_by: pageState.filterScoreTypeidx,
|
||||
region_type: pageState.filterRegionType
|
||||
})
|
||||
setTopLocations(res.data)
|
||||
|
||||
} catch (err) {
|
||||
|
@ -1,24 +1,12 @@
|
||||
import { SeparatorWithAnchor } from '../../components';
|
||||
import news from '../../datas/recent_news_event.json';
|
||||
import popular from '../../datas/popular.json';
|
||||
import critics_users_pick from '../../datas/critics_users_best_pick.json';
|
||||
import popular_user_review from '../../datas/popular_user_reviews.json';
|
||||
import './style.css';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { getListRecentLocationsRatingsService } from '../../services';
|
||||
import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type NewPlaces = {
|
||||
id: Number,
|
||||
name: string,
|
||||
thumbnail: NullValueRes<'String', string>,
|
||||
regency_name: String,
|
||||
province_name: String,
|
||||
critic_score: Number,
|
||||
critic_count: Number,
|
||||
user_score: Number,
|
||||
user_count: Number
|
||||
}
|
||||
import { LocationInfo } from '../../domains/LocationInfo';
|
||||
|
||||
type News = {
|
||||
header: string,
|
||||
@ -29,7 +17,9 @@ type News = {
|
||||
}
|
||||
|
||||
function Home() {
|
||||
const [recentLocations, setRecentLocations] = useState<Array<NewPlaces>>([])
|
||||
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>([])
|
||||
const [topCriticsLocations, setTopCriticsLocations] = useState<Array<LocationInfo>>([])
|
||||
const [topUsersLocations, setTopUsersLocations] = useState<Array<LocationInfo>>([])
|
||||
// const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
|
||||
const navigate = useNavigate()
|
||||
@ -45,6 +35,24 @@ function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCrititsBestLocations() {
|
||||
try {
|
||||
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 2, region_type: 0})
|
||||
setTopCriticsLocations(res.data)
|
||||
}catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getUsersBestLocations() {
|
||||
try {
|
||||
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 3, region_type: 0})
|
||||
setTopUsersLocations(res.data)
|
||||
}catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
function onNavigateToDetail(
|
||||
id: Number,
|
||||
critic_count: Number,
|
||||
@ -59,6 +67,8 @@ function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
getRecentLocations()
|
||||
getCrititsBestLocations()
|
||||
getUsersBestLocations()
|
||||
},[])
|
||||
return (
|
||||
<>
|
||||
@ -185,17 +195,21 @@ function Home() {
|
||||
</div>
|
||||
<div className={'col-span-2 lg:col-span-1'}>
|
||||
<SeparatorWithAnchor pageLink='#' pageName={"Critic's Best"} />
|
||||
{critics_users_pick.critics.map((x) => (
|
||||
<div className={"pt-2 text-sm"}>
|
||||
{topCriticsLocations.map((x) => (
|
||||
<div className={"pt-2 text-sm top-location-container"}>
|
||||
<div className={'mr-2 critics-users-image'}>
|
||||
<img src={x.thumbnail} loading={'lazy'} style={{ height: '100%', width: '100%', borderRadius: 3}} />
|
||||
<img
|
||||
src={x.thumbnail.Valid ? x.thumbnail.String.toString() : 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'}
|
||||
loading={'lazy'}
|
||||
style={{ height: '100%', width: '100%', borderRadius: 3}}
|
||||
/>
|
||||
</div>
|
||||
<p className={'location-title'}>{x.name}</p>
|
||||
<p className={'text-xs location-province location-title'}>{x.location}</p>
|
||||
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
||||
<div className={'critics-users-rating-container'} style={{ display: 'inline-block' }}>
|
||||
<p className={'text-xs ml-2'}>{x.critic_rating}</p>
|
||||
<p className={'text-xs ml-2'}>{x.critic_score} <span className={'text-gray'}>({x.critic_count})</span></p>
|
||||
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
|
||||
<div style={{ height: 4, width: `${x.critic_rating}%`, backgroundColor: 'green' }} />
|
||||
<div style={{ height: 4, width: `${x.critic_score}%`, backgroundColor: 'green' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ clear: 'both'}} />
|
||||
@ -205,17 +219,20 @@ function Home() {
|
||||
</div>
|
||||
<div className={'col-span-2 lg:col-span-1'}>
|
||||
<SeparatorWithAnchor pageLink='#' pageName={"User's Best"} />
|
||||
{critics_users_pick.users.map((x) => (
|
||||
<div className={"pt-2 text-sm"}>
|
||||
{topUsersLocations.map((x) => (
|
||||
<div className={"pt-2 text-sm top-location-container"}>
|
||||
<div className={'mr-2 critics-users-image'}>
|
||||
<img src={x.thumbnail} style={{ height: '100%', width: '100%', borderRadius: 3}} />
|
||||
<img
|
||||
src={x.thumbnail.Valid ? x.thumbnail.String.toString() : 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'}
|
||||
style={{ height: '100%', width: '100%', borderRadius: 3}}
|
||||
/>
|
||||
</div>
|
||||
<p className={'location-title'}>{x.name}</p>
|
||||
<p className={'text-xs location-province location-title'}>{x.location}</p>
|
||||
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
||||
<div className={'critics-users-rating-container'} style={{ display: 'inline-block' }}>
|
||||
<p className={'text-xs ml-2'}>{x.user_rating}</p>
|
||||
<p className={'text-xs ml-2'}>{x.user_score} <span className={'text-xs text-gray'}>({x.user_count})</span></p>
|
||||
<div style={{ height: 4, width: 30, backgroundColor: "#72767d"}}>
|
||||
<div style={{ height: 4, width: `${x.user_rating}%`, backgroundColor: 'green' }} />
|
||||
<div style={{ height: 4, width: `${x.user_score}%`, backgroundColor: 'green' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ clear: 'both'}} />
|
||||
|
@ -152,7 +152,6 @@
|
||||
.news-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.news-link {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
@ -178,4 +177,8 @@
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.top-location-container .location-title {
|
||||
font-size: 0.685rem;
|
||||
}
|
||||
|
||||
}
|
@ -86,6 +86,18 @@ img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.review-box-content {
|
||||
width: 75%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.review-box-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 380px) {
|
||||
.header-link {
|
||||
white-space: nowrap;
|
||||
|
@ -10,14 +10,16 @@ import {
|
||||
emptyLocationResponse,
|
||||
CurrentUserLocationReviews,
|
||||
} from './types';
|
||||
import { AxiosError } from 'axios';
|
||||
import { handleAxiosError, useAutosizeTextArea } from '../../utils';
|
||||
import { getImagesByLocationService, getLocationService, postReviewLocation } from "../../services";
|
||||
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services";
|
||||
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { UserRootState } from '../../store/type';
|
||||
import { DEFAULT_AVATAR_IMG } from '../../constants/default';
|
||||
import './index.css';
|
||||
import { AxiosError } from 'axios';
|
||||
import { IHttpResponse } from '../../types/common';
|
||||
import ReactTextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
const SORT_TYPE = [
|
||||
'highest rated',
|
||||
@ -31,6 +33,7 @@ function LocationDetail() {
|
||||
const [locationImages, setLocationImages] = useState<LocationResponse>(emptyLocationResponse())
|
||||
const [currentUserReview, setCurrentUserReview] = useState<CurrentUserLocationReviews>()
|
||||
const [lightboxOpen, setLightboxOpen] = useState<boolean>(false)
|
||||
const[updatePage, setUpdatePage] = useState<boolean>(true)
|
||||
const [pageState, setPageState] = useState({
|
||||
critic_filter_name: 'highest rated',
|
||||
critic_filter_type: 0,
|
||||
@ -74,12 +77,27 @@ function LocationDetail() {
|
||||
const res = await getImagesByLocationService({ page: 1, page_size: 15, location_id: Number(id) })
|
||||
res.data.images.push({ src: thumbnail })
|
||||
setLocationImages(res.data)
|
||||
setUpdatePage(false)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function getCurrentUserLocationReview(): Promise<void> {
|
||||
try {
|
||||
const res = await getCurrentUserLocationReviewService(Number(id))
|
||||
setCurrentUserReview(res.data)
|
||||
setPageState({ ...pageState, enable_post: false})
|
||||
} catch (error) {
|
||||
let err = error as IHttpResponse;
|
||||
if(err.status == 404 || err.status == 401 ) {
|
||||
return
|
||||
}
|
||||
alert(err.error.response.data.message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextAreaChange(e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
const val = e.target as HTMLTextAreaElement;
|
||||
|
||||
@ -150,6 +168,7 @@ function LocationDetail() {
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at
|
||||
})
|
||||
setUpdatePage(true)
|
||||
} catch (error) {
|
||||
let err = error as AxiosError;
|
||||
console.log(err)
|
||||
@ -168,8 +187,14 @@ function LocationDetail() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getLocationDetail()
|
||||
getCurrentUserLocationReview()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if(updatePage) {
|
||||
getLocationDetail()
|
||||
}
|
||||
}, [updatePage])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -300,7 +325,7 @@ function LocationDetail() {
|
||||
<div className={'bg-secondary text-center p-3'} style={{ width: '100%' }}><a href={'#'} onClick={handleSignInNavigation} style={{ borderBottom: '1px solid white' }}>SIGN IN</a> TO REVIEW</div>
|
||||
:
|
||||
<div name="REVIEW INPUT TEXTAREA" className={'reviewContainer p-4'} style={{ backgroundColor: '#2f3136' }}>
|
||||
<div className={'reviewBoxContent'} style={{ width: '75%', margin: '0 auto' }}>
|
||||
<div className={'review-box-content'}>
|
||||
|
||||
<div className={'userImage mr-3'} style={{ width: 55, float: 'left' }}>
|
||||
<a href={'#'}>
|
||||
@ -315,7 +340,6 @@ function LocationDetail() {
|
||||
<div className={'ratingInput'} style={currentUserReview ? { margin: '0 0 10px' } : { margin: '5px 0 10px' }}>
|
||||
{currentUserReview ?
|
||||
<div style={{ display: 'inline-block' }}>
|
||||
{console.log(currentUserReview)}
|
||||
<p className={'ml-2'}>{currentUserReview.score}</p>
|
||||
<div style={{ height: 4, width: 35, backgroundColor: "#72767d" }}>
|
||||
<div style={{ height: 4, width: `${currentUserReview.score}%`, backgroundColor: 'green' }} />
|
||||
@ -349,12 +373,11 @@ function LocationDetail() {
|
||||
content={currentUserReview.comments}
|
||||
/>
|
||||
:
|
||||
<textarea
|
||||
<ReactTextareaAutosize
|
||||
onChange={handleTextAreaChange}
|
||||
ref={textAreaRef}
|
||||
className={'p-2'}
|
||||
className={'p-2 text-area text-sm'}
|
||||
value={reviewValue.review_textArea}
|
||||
style={{ border: 'none', overflow: 'auto', outline: 'none', boxShadow: 'none', backgroundColor: '#40444b', width: '100%', minHeight: 100, overflowY: 'hidden' }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@ -379,67 +402,77 @@ function LocationDetail() {
|
||||
}
|
||||
<div name={'CRTICITS REVIEW'} style={{ margin: '50px 0', textAlign: 'left' }}>
|
||||
<SeparatorWithAnchor pageName={"critic's review"} pageLink='#' />
|
||||
<div className={'criticSortFilter'}>
|
||||
<div className={'inline-block text-sm'}>Sort by: </div>
|
||||
<a className={'dropdownLabel'} onClick={() => setPageState({ ...pageState, show_sort: !pageState.show_sort })}>
|
||||
<p className={'ml-2 inline-block capitalize text-sm'}>{pageState.critic_filter_name}</p>
|
||||
<svg style={{ display: 'inline-block' }} fill={"currentColor"} xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
|
||||
</a>
|
||||
<div className={'dropdown-content text-sm bg-secondary'} style={pageState.show_sort ? { display: 'block' } : ''}>
|
||||
{SORT_TYPE.map((x, index) => (
|
||||
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className={'block pt-1 capitalize'}>{x}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ clear: 'both' }} />
|
||||
|
||||
{locationDetail.critics_review.map(x => (
|
||||
<div className={''} style={{ padding: '15px 0' }}>
|
||||
<div style={{ float: 'left' }}>
|
||||
<div style={{ fontSize: 20, marginRight: 20, textAlign: 'center', width: 55, marginBottom: 3 }}>
|
||||
{x.score}
|
||||
</div>
|
||||
<div style={{ height: 4, width: 55, position: 'relative', backgroundColor: '#d8d8d8' }}>
|
||||
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={'mr-3'} style={{ display: 'inline-block', width: 40 }}>
|
||||
<a href="#">
|
||||
<img
|
||||
loading={'lazy'}
|
||||
style={{ width: '100%' }}
|
||||
src={x.user_avatar.Valid ? x.user_avatar.String.toString() : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
|
||||
/>
|
||||
{locationDetail.critics_review.length > 0 ?
|
||||
<>
|
||||
<div className={'criticSortFilter'}>
|
||||
<div className={'inline-block text-sm'}>Sort by: </div>
|
||||
<a className={'dropdownLabel'} onClick={() => setPageState({ ...pageState, show_sort: !pageState.show_sort })}>
|
||||
<p className={'ml-2 inline-block capitalize text-sm'}>{pageState.critic_filter_name}</p>
|
||||
<svg style={{ display: 'inline-block' }} fill={"currentColor"} xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'top' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}>
|
||||
<a>
|
||||
<span>{x.username}</span>
|
||||
</a>
|
||||
<div className={'dropdown-content text-sm bg-secondary'} style={pageState.show_sort ? { display: 'block' } : ''}>
|
||||
{SORT_TYPE.map((x, index) => (
|
||||
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className={'block pt-1 capitalize'}>{x}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, lineHeight: '24px', margin: '5px 75px 1px' }}>
|
||||
<CustomInterweave
|
||||
content={x.comments}
|
||||
/>
|
||||
</div>
|
||||
<div className={'reviewLinks'} style={{ marginLeft: 72 }}>
|
||||
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<a className={'text-sm'} href={'#'}>
|
||||
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
|
||||
<div className={'inline-block'}>Video</div>
|
||||
</a>
|
||||
<div style={{ clear: 'both' }} />
|
||||
|
||||
{locationDetail.critics_review.map(x => (
|
||||
<div className={''} style={{ padding: '15px 0' }}>
|
||||
<div style={{ float: 'left' }}>
|
||||
<div style={{ fontSize: 20, marginRight: 20, textAlign: 'center', width: 55, marginBottom: 3 }}>
|
||||
{x.score}
|
||||
</div>
|
||||
<div style={{ height: 4, width: 55, position: 'relative', backgroundColor: '#d8d8d8' }}>
|
||||
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={'mr-3'} style={{ display: 'inline-block', width: 40 }}>
|
||||
<a href="#">
|
||||
<img
|
||||
loading={'lazy'}
|
||||
style={{ width: '100%' }}
|
||||
src={x.user_avatar.Valid ? x.user_avatar.String.toString() : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'top' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}>
|
||||
<a>
|
||||
<span>{x.username}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, lineHeight: '24px', margin: '5px 75px 1px' }}>
|
||||
<CustomInterweave
|
||||
content={x.comments}
|
||||
/>
|
||||
</div>
|
||||
<div className={'reviewLinks'} style={{ marginLeft: 72 }}>
|
||||
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<a className={'text-sm'} href={'#'}>
|
||||
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
|
||||
<div className={'inline-block'}>Video</div>
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<a className={'text-sm'} href={'#'}>
|
||||
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
|
||||
<div className={'inline-block'}>Instagram</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<a className={'text-sm'} href={'#'}>
|
||||
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
|
||||
<div className={'inline-block'}>Instagram</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</>
|
||||
|
||||
:
|
||||
<>
|
||||
<span className={'text-sm italic'}>No Critics review to display</span>
|
||||
</>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
<div name={'USERS REVIEW'} style={{ margin: '50px 0', textAlign: 'left' }}>
|
||||
@ -605,6 +638,8 @@ function LocationDetail() {
|
||||
</div>
|
||||
{/* {screen.width >= 1024 &&
|
||||
<div className={'bg-secondary'} style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 330, textAlign: 'left', padding: 15, boxSizing: 'border-box', height: 1080 }}>
|
||||
// ADD USER DISTRIBUTION SOMETHING LIKE THIS
|
||||
// https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_user_rating
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Reprehenderit cumque aliquam doloribus in reiciendis? Laborum, ea assumenda, tempora dolore placeat aspernatur, cumque totam sequi debitis dolor nam eligendi suscipit aliquid?
|
||||
</div>
|
||||
} */}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { NullValueRes } from "../../types/common"
|
||||
import { SlideImage } from "yet-another-react-lightbox"
|
||||
|
||||
export interface ILocationDetail {
|
||||
|
@ -1,4 +1,4 @@
|
||||
input {
|
||||
td input {
|
||||
padding: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
@ -24,8 +24,14 @@ function Login() {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await loginService({ username: form.username, password: form.password })
|
||||
if (res.error) {
|
||||
setErrorMsg(res.error.response.data.errors)
|
||||
|
||||
if(res.error) {
|
||||
if (res.error.response.status == 400) {
|
||||
setErrorMsg(res.error.response.data.errors)
|
||||
return
|
||||
}
|
||||
|
||||
alert(res.error.response.data.message)
|
||||
return
|
||||
}
|
||||
|
||||
@ -72,7 +78,6 @@ function Login() {
|
||||
function onChangeInput(e: ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target as HTMLInputElement;
|
||||
setFrom({ ...form, [val.placeholder]: val.value })
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@ -86,11 +91,14 @@ function Login() {
|
||||
<td><input value={form.username} onChange={onChangeInput} placeholder={'username'} className={'bg-secondary'} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>password: </td>
|
||||
<td>password: </td>
|
||||
<td><input value={form.password} onChange={onChangeInput} type={'password'} placeholder={'password'} className={'bg-secondary'} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{errorMsg.map(x => (
|
||||
<p>{x.msg}</p>
|
||||
))}
|
||||
<a className={'block'}>Forgout account ?</a>
|
||||
<input type={'submit'} value={'sign in'} className={'p-1 text-sm text-primary mt-4'} style={{ backgroundColor: '#a8adb3', borderRadius: 7 }} />
|
||||
</form>
|
||||
|
241
src/pages/NewsEvent/index.tsx
Normal file
241
src/pages/NewsEvent/index.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import { ChangeEvent, TargetedEvent, useEffect, useRef, useState } from "preact/compat";
|
||||
import { DefaultButton, NavigationSeparator } from "../../components";
|
||||
import { News } from "../../domains/NewsEvent";
|
||||
import { getNewsServices, postNewsService } from "../../../src/services";
|
||||
import { AxiosError } from "axios";
|
||||
import { DEFAULT_LOCATION_THUMBNAIL_IMG } from "../../../src/constants/default";
|
||||
import { isUrl, useAutosizeTextArea } from "../../../src/utils";
|
||||
import ReactTextareaAutosize from "react-textarea-autosize";
|
||||
import { IHttpResponse } from "../../../src/types/common";
|
||||
import { useSelector } from "react-redux";
|
||||
import { UserRootState } from "src/store/type";
|
||||
import "./style.css"
|
||||
import useCallbackState from "../../../src/types/state-callback";
|
||||
|
||||
function NewsEvent() {
|
||||
const [news, setNews] = useState<Array<News>>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pageState, setPageState] = useState({
|
||||
news_category_name: "hot",
|
||||
page: 1,
|
||||
})
|
||||
|
||||
const user = useSelector((state: UserRootState) => state.auth)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
description: '',
|
||||
url: '',
|
||||
title: '',
|
||||
title_error_msg: '',
|
||||
link_error_msg: '',
|
||||
})
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
useAutosizeTextArea(textAreaRef.current, form.description);
|
||||
|
||||
async function getNewsEvents(approved: number = 1) {
|
||||
try {
|
||||
const news = await getNewsServices({ page: pageState.page, page_size: 10, is_with_approval: approved})
|
||||
if (news.status == 200) {
|
||||
setNews(news.data)
|
||||
}
|
||||
console.log(news)
|
||||
} catch (error) {
|
||||
let err = error as AxiosError;
|
||||
if (!err.status) {
|
||||
alert('Server is in trouble, probably dead RIP');
|
||||
}
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextAreaChange(e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
const val = e.target as HTMLTextAreaElement;
|
||||
|
||||
setForm({
|
||||
...form,
|
||||
description: val.value
|
||||
})
|
||||
}
|
||||
|
||||
function handleInputChange(e: ChangeEvent): void {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
|
||||
setForm({
|
||||
...form,
|
||||
[target.name]: target.value,
|
||||
link_error_msg: '',
|
||||
title_error_msg: ''
|
||||
})
|
||||
}
|
||||
|
||||
function isFormEmpty() {
|
||||
const { url, title} = form;
|
||||
let url_error = ''
|
||||
let title_error = ''
|
||||
|
||||
if(url == '') url_error = "url mustn't empty"
|
||||
if(title == '') title_error = "title mustn't empty"
|
||||
|
||||
return {url_error, title_error}
|
||||
}
|
||||
|
||||
function handleOnChangeNewsCategory(e: TargetedEvent, name: string) {
|
||||
e.preventDefault();
|
||||
|
||||
if(name.toLowerCase() === "fresh") {
|
||||
setPageState({ ...pageState, news_category_name: "fresh" })
|
||||
getNewsEvents(0)
|
||||
return
|
||||
}
|
||||
|
||||
setPageState({ ...pageState, news_category_name: "hot" })
|
||||
getNewsEvents(1)
|
||||
}
|
||||
|
||||
async function handleSumbitNews(e: TargetedEvent) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true)
|
||||
|
||||
const {url_error, title_error } = isFormEmpty()
|
||||
if(url_error !== "" || title_error !== "") {
|
||||
setForm({ ...form, link_error_msg: url_error, title_error_msg: title_error})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if(!isUrl(form.url)) {
|
||||
setForm({ ...form, link_error_msg: 'url is not correct(https://validurl.com")'})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await postNewsService({
|
||||
description: form.description,
|
||||
submitted_by: user.id,
|
||||
title: form.title,
|
||||
url: form.url
|
||||
})
|
||||
if(response.status == 201) {
|
||||
alert("success")
|
||||
setForm({
|
||||
link_error_msg: '',
|
||||
title_error_msg: '',
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
}
|
||||
setIsLoading(false)
|
||||
} catch(error) {
|
||||
setIsLoading(false)
|
||||
let err = error as IHttpResponse;
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNewsEvents()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={'content main-content'}>
|
||||
<h1 className={'text-2xl mb-5 font-bold'}>News / Events</h1>
|
||||
<div style={{ maxWidth: 1003 }}>
|
||||
<NavigationSeparator
|
||||
pageNames={['Hot', 'Fresh']}
|
||||
onClick={handleOnChangeNewsCategory}
|
||||
selectedValue={pageState.news_category_name}
|
||||
/>
|
||||
<section name="news content">
|
||||
{news.map(x => (
|
||||
<div style={{ marginBottom: 15, paddingBottom: 15, borderBottom: '1px solid #38444d', marginTop: 30 }}>
|
||||
<a href={x.url} rel={"nofollow"} target={"_"}>
|
||||
<div style={{ float: 'left', marginRight: 20, width: 200 }}>
|
||||
<img
|
||||
src={x.thumbnail != '' ? x.thumbnail : DEFAULT_LOCATION_THUMBNAIL_IMG}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div className={'text-2xl font-bold'}>
|
||||
<a href={x.url} target={"_"} rel={"nofollow"}>{x.title}</a>
|
||||
</div>
|
||||
|
||||
<div className={'text-xs mt-1'}>
|
||||
<div className={'inline-block text-yellow font-bold'}>
|
||||
{x.source}
|
||||
</div>
|
||||
<div className={'submitted-info'} style={{ display: 'inline-block' }}>
|
||||
1d ago by
|
||||
<a> {x.submitted_by}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'text-xs text-gray mt-6'}>
|
||||
<a className={'inline-block'}>
|
||||
<svg className={'inline-block'} xmlns="http://www.w3.org/2000/svg" fill={"gray"} height="18" viewBox="0 -960 960 960" width="18"><path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z" /></svg>
|
||||
<p className={"mr-3 ml-1 inline-block"}>10</p>
|
||||
</a>
|
||||
<a className={'inline-block'}>
|
||||
<svg className={'inline-block'} style={{ marginTop: 2 }} stroke="currentColor" fill="white" stroke-width="0" viewBox="0 0 512 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128 272c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>
|
||||
<p className={"ml-1 inline-block"}>12</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ clear: 'both' }} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section name={"news input"}>
|
||||
<div className={'bg-secondary'} style={{ maxWidth: '100%', padding: '10px 10px'}}>
|
||||
<h1>SUBMIT A NEWS / EVENT</h1>
|
||||
<h2 className={'mt-3'}>Title</h2>
|
||||
<input
|
||||
className={'input-text'}
|
||||
style={{ background: '#40444b'}}
|
||||
maxLength={150}
|
||||
name={'title'}
|
||||
placeholder={'Konser besar akbar agung di bengkulu'}
|
||||
onChange={handleInputChange}
|
||||
value={form.title}
|
||||
/>
|
||||
<span className={'text-xs ml-3 text-error'}>{form.title_error_msg}</span>
|
||||
<h2 className={'mt-3'}>Link</h2>
|
||||
<input
|
||||
className={'input-text'}
|
||||
style={{ background: '#40444b'}}
|
||||
name={'url'}
|
||||
value={form.url}
|
||||
placeholder={'https://tourismnews.com/12'}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<span className={'text-xs ml-3 text-error'}>{form.link_error_msg}</span>
|
||||
<h2 className={'mt-3'}>Description (optional) </h2>
|
||||
<ReactTextareaAutosize
|
||||
class="text-area p-2"
|
||||
onChange={handleTextAreaChange}
|
||||
value={form.description}
|
||||
/>
|
||||
<DefaultButton
|
||||
label="submit"
|
||||
containerClassName="mt-5"
|
||||
containerStyle={{ float: 'right'}}
|
||||
style={{ padding: '5px 10px', letterSpacing: 0}}
|
||||
isLoading={isLoading}
|
||||
onClick={handleSumbitNews}
|
||||
/>
|
||||
<div style={{ clear: 'both'}} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default NewsEvent;
|
4
src/pages/NewsEvent/style.css
Normal file
4
src/pages/NewsEvent/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
.submitted-info::before {
|
||||
content: " // ";
|
||||
margin-left: 5px;
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
function NewsEvent() {
|
||||
return(
|
||||
<>
|
||||
<h1>Best PLaces</h1>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default NewsEvent;
|
9
src/pages/Submissions/index.tsx
Normal file
9
src/pages/Submissions/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
function Submissions() {
|
||||
return (
|
||||
<div className={'content main-content'}>
|
||||
<h1>Submssions</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Submissions;
|
9
src/pages/UserFeed/index.tsx
Normal file
9
src/pages/UserFeed/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
function UserFeed() {
|
||||
return (
|
||||
<div>
|
||||
<h1>User feed</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserFeed;
|
226
src/pages/UserProfile/index.tsx
Normal file
226
src/pages/UserProfile/index.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { DEFAULT_AVATAR_IMG } from "../../constants/default";
|
||||
import { UserRootState } from "../../store/type";
|
||||
import "./style.css"
|
||||
import { useEffect, useState } from "preact/compat";
|
||||
import { getUserStatsService } from "../../services";
|
||||
import { CustomInterweave, SeparatorWithAnchor } from "../../components";
|
||||
|
||||
interface UserStats {
|
||||
followers: number,
|
||||
score_count: number
|
||||
}
|
||||
|
||||
interface UserStatsReviews {
|
||||
id: number,
|
||||
comments: string,
|
||||
name: string,
|
||||
province_name: string,
|
||||
score: number,
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
interface ScoresDistrbution {
|
||||
score: number
|
||||
}
|
||||
|
||||
interface UserStatsResponse {
|
||||
reviews: Array<UserStatsReviews>,
|
||||
user_stats: UserStats,
|
||||
scores_distribution: Array<ScoresDistrbution>
|
||||
}
|
||||
|
||||
const emptyUserStatsResponse: UserStatsResponse = {
|
||||
reviews: Array<UserStatsReviews>(),
|
||||
user_stats: {
|
||||
followers: 0,
|
||||
score_count: 0
|
||||
},
|
||||
scores_distribution: [
|
||||
{
|
||||
score: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function UserProfile() {
|
||||
const [userStats, setUserStats] = useState<UserStatsResponse>(emptyUserStatsResponse);
|
||||
|
||||
const user = useSelector((state: UserRootState) => state.auth)
|
||||
|
||||
async function getUserStats() {
|
||||
try {
|
||||
const res = await getUserStatsService()
|
||||
setUserStats(res.data)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
function scoreDistributionLabel(val: string) {
|
||||
if (val === '0') {
|
||||
return `${val}-9`
|
||||
}
|
||||
|
||||
if (val === '99') {
|
||||
return '100'
|
||||
}
|
||||
|
||||
return `${val}0-${val}9`
|
||||
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getUserStats()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={'content main-content'}>
|
||||
<div name={'profile_header'} className={'flex column items-end mb-4'}>
|
||||
<div className={'flex-1'}>
|
||||
<img
|
||||
src={user.avatar_picture !== '' ? user.avatar_picture : DEFAULT_AVATAR_IMG}
|
||||
style={{ width: 140, aspectRatio: '1/1', float: 'left' }}
|
||||
className={'mr-4'}
|
||||
/>
|
||||
<p className={'text-lg'}>{user.username}</p>
|
||||
{/* <div className={'mt-2'}>
|
||||
<button className={'bg-tertiary text-xs mr-2'} style={{ padding: '5px 15px', letterSpacing: .8}}>
|
||||
FOLLOW
|
||||
</button>
|
||||
<button className={'bg-error text-xs'} style={{ padding: '5px 15px', letterSpacing: .8}}>
|
||||
REPORT
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className={'inline-block mr-4 text-center'}>
|
||||
<p className={'font-bold'} style={{ fontSize: 32 }}>{userStats?.user_stats.followers}</p>
|
||||
<p className={'text-xs'}>Followers</p>
|
||||
</div>
|
||||
<div className={'inline-block text-center'}>
|
||||
<p className={'font-bold'} style={{ fontSize: 32 }}>{userStats?.user_stats.score_count}</p>
|
||||
<p className={'text-xs'}>Reviews</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section name={"profile-navigation"}>
|
||||
<div className={'bg-secondary profile-nav'}>
|
||||
<a>
|
||||
<div>
|
||||
summary
|
||||
</div>
|
||||
</a>
|
||||
<a>
|
||||
<div>
|
||||
reviews
|
||||
</div>
|
||||
</a>
|
||||
<a>
|
||||
<div>
|
||||
likes
|
||||
</div>
|
||||
</a>
|
||||
<a>
|
||||
<div>
|
||||
stories
|
||||
</div>
|
||||
</a>
|
||||
<a>
|
||||
<div>
|
||||
tags
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section name={'REVIEWS SECTION'}>
|
||||
<div className={'mt-4'} style={{ tableLayout: 'fixed', display: 'table', width: '100%' }}>
|
||||
<div className={'wideLeft'} style={{ textAlign: 'left', paddingRight: 20, maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%', boxSizing: 'border-box' }}>
|
||||
<div name={'USERS REVIEW'} style={{ textAlign: 'left' }}>
|
||||
<SeparatorWithAnchor pageName={"User's review"} pageLink='#' secondLink={userStats!.reviews.length > 0 ? '#' : ''} />
|
||||
{userStats!.reviews.length > 0 ?
|
||||
<>
|
||||
{userStats!.reviews.map(x => (
|
||||
<div style={{ padding: '15px 0' }}>
|
||||
<div className={'mr-3'} style={{ width: 65, float: 'left' }}>
|
||||
<a href="#">
|
||||
<img
|
||||
loading={'lazy'}
|
||||
style={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
|
||||
src={x.thumbnail !== '' ? x.thumbnail : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}>
|
||||
<a>
|
||||
<span>{x.name}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className={'text-xs'}>{x.province_name}</p>
|
||||
<div className={'inline-block'}>
|
||||
<div className={'text-sm text-center'} >{x.score}</div>
|
||||
<div style={{ height: 4, width: 25, position: 'relative', backgroundColor: '#d8d8d8' }}>
|
||||
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, lineHeight: '24px', margin: '10px 77px 1px', wordWrap: 'break-word' }}>
|
||||
<CustomInterweave
|
||||
content={x.comments}
|
||||
/>
|
||||
</div>
|
||||
<div className={'reviewLinks'} style={{ marginLeft: 76 }}>
|
||||
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<a className={'text-sm'} href={'#'}>
|
||||
<svg className={'inline-block mr-1'} xmlns="http://www.w3.org/2000/svg" fill={"gray"} height="14" viewBox="0 -960 960 960" width="14"><path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z" /></svg>
|
||||
<div className={'inline-block'}>24</div>
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle', }}>
|
||||
<a className={'text-sm'} href={'#'}>
|
||||
<svg className={'inline-block mr-1'} stroke="currentColor" fill="white" stroke-width="0" viewBox="0 0 512 512" height="14" width="14" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128 272c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>
|
||||
<div className={'inline-block'}>25 </div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<span className={'text-sm italic'}>No users review to display</span>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{screen.width >= 1024 &&
|
||||
<div className={'bg-secondary'} style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 330, textAlign: 'left', boxSizing: 'border-box' }}>
|
||||
<div style={{ padding: '20px 10px' }}>
|
||||
<div className={'bg-primary p-2'}>
|
||||
<SeparatorWithAnchor
|
||||
pageName={"Score Distribution"}
|
||||
pageLink="#"
|
||||
titleStyles={{ fontSize: 12, letterSpacing: .9 }}
|
||||
/>
|
||||
{Object.entries(userStats.scores_distribution[0]).map(x => (
|
||||
<div style={{ height: 15, margin: '5px 0'}}>
|
||||
<div style={{ float: 'left', marginRight: 5, width: 40, fontSize: 11, textAlign: 'center' }}>{scoreDistributionLabel(x[0])}</div>
|
||||
<div style={{ borderTopRightRadius: 3, borderBottomRightRadius: 3, height: 13, float: 'left', marginRight: 5, minWidth: 1, backgroundColor: '#0be881', width: `calc(${x[1]/userStats.user_stats.score_count * 100}%/2)` }}></div>
|
||||
<div style={{ float: 'left', fontSize: 11 }}>{x[1]}</div>
|
||||
<br style={{ clear: 'both' }} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div style={{ clear: 'both' }} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile;
|
13
src/pages/UserProfile/style.css
Normal file
13
src/pages/UserProfile/style.css
Normal file
@ -0,0 +1,13 @@
|
||||
.profile-nav {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-nav div {
|
||||
display: inline-block;
|
||||
padding: 0 2%;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.75rem;
|
||||
}
|
286
src/pages/UserSettings/index.tsx
Normal file
286
src/pages/UserSettings/index.tsx
Normal 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;
|
3
src/pages/UserSettings/styles.css
Normal file
3
src/pages/UserSettings/styles.css
Normal file
@ -0,0 +1,3 @@
|
||||
.button-avatar-container p a:hover {
|
||||
color: white;
|
||||
}
|
@ -2,20 +2,30 @@ import Home from "./Home";
|
||||
import BestLocation from "./BestLocations";
|
||||
import Discovery from "./Discovery";
|
||||
import Story from "./Stories";
|
||||
import NewsEvent from "./NewsEvents";
|
||||
import NewsEvent from "./NewsEvent";
|
||||
import LocationDetail from "./LocationDetail";
|
||||
import Login from './Login';
|
||||
import NotFound from "./NotFound";
|
||||
import AddLocation from "./AddLocation";
|
||||
import Submissions from "./Submissions";
|
||||
import UserProfile from "./UserProfile";
|
||||
import UserFeed from "./UserFeed";
|
||||
import UserSettings from "./UserSettings";
|
||||
|
||||
export {
|
||||
Login,
|
||||
|
||||
Home,
|
||||
UserProfile,
|
||||
UserFeed,
|
||||
UserSettings,
|
||||
|
||||
NotFound,
|
||||
|
||||
BestLocation,
|
||||
AddLocation,
|
||||
LocationDetail,
|
||||
Submissions,
|
||||
|
||||
Discovery,
|
||||
Story,
|
||||
|
23
src/routes/ProtectedRoute.tsx
Normal file
23
src/routes/ProtectedRoute.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Navigate } from "react-router-dom"
|
||||
import { useSelector } from "react-redux"
|
||||
import { UserRootState } from "src/store/type"
|
||||
|
||||
|
||||
export const AdminProtectedRoute = ({children}: any) => {
|
||||
|
||||
const user = useSelector((state: UserRootState) => state.auth)
|
||||
if(!user.is_admin) {
|
||||
return <Navigate to={"/"} replace />;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
@ -5,40 +5,94 @@ import {
|
||||
LocationDetail,
|
||||
NewsEvent,
|
||||
Story,
|
||||
Login
|
||||
AddLocation,
|
||||
UserProfile,
|
||||
UserFeed,
|
||||
UserSettings,
|
||||
Submissions
|
||||
} from '../pages';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
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 />
|
||||
}
|
||||
]
|
||||
interface BaseRoutes {
|
||||
path: string,
|
||||
name: string,
|
||||
protectedRoute?: string,
|
||||
element: any
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { LOGIN_URI, SIGNUP_URI } from "../constants/api";
|
||||
import { client } from "./config";
|
||||
import { IHttpResponse } from "../types/common";
|
||||
|
||||
const initialState: IEmptyResponseState = {
|
||||
const initialState: IHttpResponse = {
|
||||
data: null,
|
||||
error: AxiosError
|
||||
}
|
||||
@ -15,7 +16,7 @@ interface IAuthentication {
|
||||
async function createAccountService({ username, password }: IAuthentication) {
|
||||
const newState = { ...initialState };
|
||||
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.error = null
|
||||
return newState
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { GetRequestPagination } from "../types/common"
|
||||
import { GET_IMAGES_BY_LOCATION_URI } from "../constants/api"
|
||||
import { client } from "./config"
|
||||
import statusCode from "./status-code"
|
||||
|
@ -7,13 +7,22 @@ import {
|
||||
} from "./locations";
|
||||
import { getImagesByLocationService } from "./images"
|
||||
import { createAccountService, loginService, logoutService } from "./auth";
|
||||
import { postReviewLocation } from "./review";
|
||||
import { postReviewLocation, getCurrentUserLocationReviewService } from "./review";
|
||||
import { getRegionsService, getProvincesService, getRegenciesService} from "./regions";
|
||||
import { getUserStatsService } from "./users";
|
||||
import { getNewsServices, postNewsService} from "./news";
|
||||
|
||||
export {
|
||||
createAccountService,
|
||||
loginService,
|
||||
logoutService,
|
||||
|
||||
getRegionsService,
|
||||
getProvincesService,
|
||||
getRegenciesService,
|
||||
|
||||
getUserStatsService,
|
||||
|
||||
getListLocationsService,
|
||||
getListRecentLocationsRatingsService,
|
||||
getListTopLocationsService,
|
||||
@ -21,5 +30,9 @@ export {
|
||||
getLocationTagsService,
|
||||
getImagesByLocationService,
|
||||
|
||||
postReviewLocation
|
||||
postReviewLocation,
|
||||
getCurrentUserLocationReviewService,
|
||||
|
||||
getNewsServices,
|
||||
postNewsService
|
||||
}
|
@ -1,18 +1,27 @@
|
||||
import { GET_LIST_LOCATIONS_URI, GET_LIST_RECENT_LOCATIONS_RATING_URI, GET_LIST_TOP_LOCATIONS, GET_LOCATION_TAGS_URI, GET_LOCATION_URI } from "../constants/api";
|
||||
import { GetRequestPagination, IHttpResponse } from "../types/common";
|
||||
import {
|
||||
GET_LIST_LOCATIONS_URI,
|
||||
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
||||
GET_LIST_TOP_LOCATIONS,
|
||||
GET_LOCATION_TAGS_URI,
|
||||
GET_LOCATION_URI,
|
||||
POST_CREATE_LOCATION
|
||||
} from "../constants/api";
|
||||
import { client } from "./config";
|
||||
import statusCode from "./status-code";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
const initialState: any = {
|
||||
data: null,
|
||||
error: null
|
||||
}
|
||||
|
||||
interface getListLocationsArg extends GetRequestPagination {
|
||||
interface GetListLocationsArg extends GetRequestPagination {
|
||||
order_by?: number,
|
||||
region_type?: number
|
||||
}
|
||||
|
||||
async function getListLocationsService({ page, page_size }: getListLocationsArg) {
|
||||
async function getListLocationsService({ page, page_size }: GetListLocationsArg) {
|
||||
const newState = { ...initialState };
|
||||
const url = `${GET_LIST_LOCATIONS_URI}?page=${page}&page_size=${page_size}`
|
||||
try {
|
||||
@ -48,7 +57,7 @@ async function getListRecentLocationsRatingsService(page_size: Number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getListTopLocationsService({ page, page_size, order_by, region_type }: getListLocationsArg) {
|
||||
async function getListTopLocationsService({ page, page_size, order_by, region_type }: GetListLocationsArg) {
|
||||
const newState = { ...initialState };
|
||||
const url = `${GET_LIST_TOP_LOCATIONS}?page=${page}&page_size=${page_size}&order_by=${order_by}®ion_type=${region_type}`
|
||||
try {
|
||||
@ -102,10 +111,26 @@ async function getLocationTagsService(id: Number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createLocationService(data: FormData): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = { data: null, error: null};
|
||||
try {
|
||||
const response = await client({ method: 'POST', url: POST_CREATE_LOCATION, data: data, withCredentials: true})
|
||||
newState.data = response.data;
|
||||
newState.status = response.status
|
||||
return newState;
|
||||
} catch (error) {
|
||||
let err = error as AxiosError;
|
||||
newState.error = err;
|
||||
newState.status = err.status;
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getListLocationsService,
|
||||
getListRecentLocationsRatingsService,
|
||||
getListTopLocationsService,
|
||||
getLocationTagsService,
|
||||
getLocationService
|
||||
getLocationService,
|
||||
createLocationService,
|
||||
}
|
50
src/services/news.ts
Normal file
50
src/services/news.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { GetRequestPagination, IHttpResponse } from "..//types/common";
|
||||
import { client } from "./config";
|
||||
import { GET_NEWS_EVENTS_URI, POST_NEWS_EVENTS_URI } from "../../src/constants/api";
|
||||
|
||||
interface GetNewsSevice extends GetRequestPagination {
|
||||
is_with_approval: number
|
||||
}
|
||||
|
||||
async function getNewsServices({ page, page_size, is_with_approval}: GetNewsSevice): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = { data: null, error: null};
|
||||
try {
|
||||
const response = await client({ method: 'GET', url: `${GET_NEWS_EVENTS_URI}?page=${page}&page_size=${page_size}&is_with_approval=${is_with_approval}`});
|
||||
newState.data = response.data;
|
||||
newState.status = response.status;
|
||||
return newState;
|
||||
} catch (error) {
|
||||
let err = error as AxiosError;
|
||||
newState.error = err;
|
||||
newState.status = err.status
|
||||
throw(newState)
|
||||
}
|
||||
}
|
||||
|
||||
interface PostNewsServiceBody {
|
||||
title: string,
|
||||
url: string,
|
||||
description: string,
|
||||
submitted_by: number
|
||||
}
|
||||
|
||||
async function postNewsService(req: PostNewsServiceBody): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = { data: null, error: null}
|
||||
try {
|
||||
const response = await client({ method: 'POST', url: POST_NEWS_EVENTS_URI, data: req, withCredentials: true})
|
||||
newState.data = response.data
|
||||
newState.status = response.status
|
||||
return newState
|
||||
} catch (error) {
|
||||
let err = error as AxiosError;
|
||||
newState.error = err;
|
||||
newState.status = err.status
|
||||
throw(newState)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getNewsServices,
|
||||
postNewsService
|
||||
}
|
46
src/services/regions.ts
Normal file
46
src/services/regions.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { client } from "./config";
|
||||
import { GET_PROVINCES, GET_REGENCIES, GET_REGIONS } from "../constants/api";
|
||||
import { IHttpResponse } from "src/types/common";
|
||||
|
||||
async function getRegionsService(): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = {data: null, error: null}
|
||||
try {
|
||||
const response = await client({ method: 'GET', url: GET_REGIONS})
|
||||
newState.data = response.data;
|
||||
return newState
|
||||
} catch(err) {
|
||||
newState.error = err
|
||||
throw (newState)
|
||||
}
|
||||
}
|
||||
|
||||
async function getProvincesService(): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = { data: null, error: null}
|
||||
try {
|
||||
const response = await client({ method: 'GET', url: GET_PROVINCES})
|
||||
newState.data = response.data;
|
||||
return newState
|
||||
} catch(err) {
|
||||
newState.error = err
|
||||
throw (newState)
|
||||
}
|
||||
}
|
||||
|
||||
async function getRegenciesService(): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = { data: null, error: null};
|
||||
try {
|
||||
const response = await client({ method: 'GET', url: GET_REGENCIES})
|
||||
newState.data = response.data;
|
||||
newState.status = response.status
|
||||
return newState
|
||||
} catch(err) {
|
||||
newState.error = err
|
||||
throw (newState)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getRegionsService,
|
||||
getProvincesService,
|
||||
getRegenciesService,
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import { AxiosError } from "axios"
|
||||
import { client } from "./config";
|
||||
import { POST_REVIEW_LOCATION_URI } from "../constants/api";
|
||||
import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_LOCATION_URI } from "../constants/api";
|
||||
|
||||
const initialState: IEmptyResponseState = {
|
||||
const initialState: IHttpResponse = {
|
||||
data: null,
|
||||
error: AxiosError
|
||||
error: AxiosError,
|
||||
status: 0,
|
||||
}
|
||||
|
||||
interface postReviewLocationReq {
|
||||
@ -29,6 +30,22 @@ async function postReviewLocation(req: postReviewLocationReq) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUserLocationReviewService(location_id: number): Promise<IHttpResponse> {
|
||||
const newState = { ...initialState };
|
||||
try {
|
||||
const response = await client({ method: 'GET', url: `${GET_CURRENT_USER_REVIEW_LOCATION_URI}/${location_id}`, withCredentials: true})
|
||||
newState.data = response.data
|
||||
newState.error = null
|
||||
return newState
|
||||
} catch (err) {
|
||||
let error = err as AxiosError;
|
||||
newState.error = error
|
||||
newState.status = error.response?.status;
|
||||
throw(newState)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
postReviewLocation
|
||||
postReviewLocation,
|
||||
getCurrentUserLocationReviewService,
|
||||
}
|
73
src/services/users.ts
Normal file
73
src/services/users.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { DELETE_USER_AVATAR, GET_CURRENT_USER_STATS, PATCH_USER_AVATAR, PATCH_USER_INFO } from "../constants/api";
|
||||
import { IHttpResponse } from "../types/common";
|
||||
import { client } from "./config";
|
||||
import { UserInfo } from "../../src/domains/User";
|
||||
|
||||
|
||||
async function getUserStatsService(): Promise<IHttpResponse> {
|
||||
const newState: IHttpResponse = { data: null, error: null };
|
||||
try {
|
||||
const res = await client({ method: 'GET', url: GET_CURRENT_USER_STATS, 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 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 {
|
||||
getUserStatsService,
|
||||
patchUserAvatarService,
|
||||
deleteUserAvatarService,
|
||||
patchUserInfoService
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { IUser } from "../features/auth/authSlice/type";
|
||||
import { User } from "../domains";
|
||||
import { RootState } from "./config";
|
||||
|
||||
export interface UserRootState extends RootState {
|
||||
auth: IUser
|
||||
auth: User
|
||||
}
|
@ -1,13 +1,44 @@
|
||||
type BaseNullValueRes = { Valid: boolean };
|
||||
type NullValueRes<Key extends string, _> = BaseNullValueRes & Record<Key, string | number>
|
||||
import { Province, Regency, Region } from "../domains";
|
||||
|
||||
interface GetRequestPagination {
|
||||
type BaseNullValueRes = { Valid: boolean };
|
||||
export type NullValueRes<Key extends string, _> = BaseNullValueRes & Record<Key, string | number>
|
||||
|
||||
export interface GetRequestPagination {
|
||||
page: number,
|
||||
page_size: number,
|
||||
}
|
||||
|
||||
|
||||
interface IEmptyResponseState {
|
||||
export interface IHttpResponse {
|
||||
data: any,
|
||||
error: any,
|
||||
};
|
||||
status?: number,
|
||||
};
|
||||
|
||||
export interface IDropdownInputProps {
|
||||
label: string,
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IndonesiaRegionsInfo {
|
||||
regions?: Array<Region>,
|
||||
provinces?: Array<Province>,
|
||||
regencies?: Array<Regency>,
|
||||
}
|
||||
|
||||
export enum LocationType {
|
||||
Beach = "beach",
|
||||
AmusementPark = "amusement park",
|
||||
Culinary = "culinary",
|
||||
HikingCamping = "hiking / camping",
|
||||
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"
|
||||
}
|
@ -2,4 +2,13 @@ import { AxiosError } from "axios";
|
||||
|
||||
export function handleAxiosError(error: AxiosError) {
|
||||
return error.response?.data
|
||||
}
|
||||
|
||||
export function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
return Object.keys(obj).filter(k => Number.isNaN(+k)) as K[];
|
||||
}
|
||||
|
||||
export function isUrl(val: string): boolean {
|
||||
var urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
||||
return urlPattern.test(val);
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import useAutosizeTextArea from "./useAutosizeTextArea";
|
||||
import { handleAxiosError } from "./common";
|
||||
import { handleAxiosError, enumKeys, isUrl } from "./common";
|
||||
|
||||
export {
|
||||
useAutosizeTextArea,
|
||||
handleAxiosError
|
||||
handleAxiosError,
|
||||
enumKeys,
|
||||
isUrl
|
||||
}
|
@ -9,6 +9,8 @@ export default {
|
||||
secondary: '#2f3136',
|
||||
tertiary: '#a8adb3',
|
||||
error: '#ff5454',
|
||||
gray: '#797979',
|
||||
yellow: '#e5c453'
|
||||
},
|
||||
borderColor: {
|
||||
primary: '#38444d',
|
||||
|
33
yarn.lock
33
yarn.lock
@ -204,6 +204,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.20.13":
|
||||
version "7.23.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d"
|
||||
integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
|
||||
@ -1220,6 +1227,15 @@ react-router@6.16.0:
|
||||
dependencies:
|
||||
"@remix-run/router" "1.9.0"
|
||||
|
||||
react-textarea-autosize@^8.5.3:
|
||||
version "8.5.3"
|
||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409"
|
||||
integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.13"
|
||||
use-composed-ref "^1.3.0"
|
||||
use-latest "^1.2.1"
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
@ -1403,6 +1419,23 @@ update-browserslist-db@^1.0.11:
|
||||
escalade "^3.1.1"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
use-composed-ref@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
|
||||
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
|
||||
|
||||
use-isomorphic-layout-effect@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
||||
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
||||
|
||||
use-latest@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2"
|
||||
integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==
|
||||
dependencies:
|
||||
use-isomorphic-layout-effect "^1.1.1"
|
||||
|
||||
use-sync-external-store@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
|
Loading…
Reference in New Issue
Block a user