feat: build settings page for profile, re-calc navHeight on resize, move the navHeight from js to css var

This commit is contained in:
MTG2000
2022-08-06 13:10:49 +03:00
parent 916bd8387b
commit ea2e142baa
15 changed files with 427 additions and 55 deletions

View File

@@ -29,6 +29,7 @@ const DonatePage = Loadable(React.lazy(() => import("./features/Donations/pages/
const LoginPage = Loadable(React.lazy(() => import("./features/Auth/pages/LoginPage/LoginPage")))
const LogoutPage = Loadable(React.lazy(() => import("./features/Auth/pages/LogoutPage/LogoutPage")))
const ProfilePage = Loadable(React.lazy(() => import("./features/Profiles/pages/ProfilePage/ProfilePage")))
const EditProfilePage = Loadable(React.lazy(() => import("./features/Profiles/pages/EditProfilePage/EditProfilePage")))
@@ -104,7 +105,9 @@ function App() {
<Route path={PAGES_ROUTES.donate.default} element={<DonatePage />} />
<Route path={PAGES_ROUTES.profile.editProfile} element={<EditProfilePage />} />
<Route path={PAGES_ROUTES.profile.byId} element={<ProfilePage />} />
<Route path={PAGES_ROUTES.auth.login} element={<LoginPage />} />
<Route path={PAGES_ROUTES.auth.logout} element={<LogoutPage />} />

View File

@@ -178,6 +178,16 @@ export default function NavDesktop() {
>
👾 Profile
</MenuItem>
<MenuItem
href="/edit-profile"
onClick={(e) => {
e.syntheticEvent.preventDefault();
navigate("/edit-profile");
}}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Settings
</MenuItem>
<MenuItem
href="/logout"
onClick={(e) => {

View File

@@ -105,6 +105,16 @@ export default function NavMobile() {
>
👾 Profile
</MenuItem>
<MenuItem
href="/edit-profile"
onClick={(e) => {
e.syntheticEvent.preventDefault();
navigate("/edit-profile");
}}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Settings
</MenuItem>
<MenuItem
href="/logout"
onClick={(e) => {

View File

@@ -1,7 +1,7 @@
import NavMobile from "./NavMobile";
import { MdComment, MdHomeFilled, MdLocalFireDepartment } from "react-icons/md";
import { useEffect, } from "react";
import { useAppDispatch, useMediaQuery } from "src/utils/hooks";
import { useCallback, useEffect, } from "react";
import { useAppDispatch, useMediaQuery, useResizeListener } from "src/utils/hooks";
import { setNavHeight } from "src/redux/features/ui.slice";
import NavDesktop from "./NavDesktop";
import { MEDIA_QUERIES } from "src/utils/theme/media_queries";
@@ -43,18 +43,24 @@ export default function Navbar() {
const isLargeScreen = useMediaQuery(MEDIA_QUERIES.isLarge)
useEffect(() => {
const updateNavHeight = useCallback(() => {
const nav = document.querySelector("nav");
if (nav) {
const navStyles = getComputedStyle(nav);
if (navStyles.display !== "none") {
dispatch(setNavHeight(nav.clientHeight));
document.documentElement.style.setProperty('--navHeight', nav.clientHeight + 'px')
}
}
}, [dispatch])
useEffect(() => {
updateNavHeight();
}, [updateNavHeight]);
useResizeListener(updateNavHeight)
return (
<div className="sticky top-0 left-0 w-full z-[2010]">

View File

@@ -2,7 +2,6 @@
import { useState } from 'react'
import Button from 'src/Components/Button/Button'
import { useGetHackathonsQuery } from 'src/graphql'
import { useAppSelector } from 'src/utils/hooks'
import HackathonsList from '../../Components/HackathonsList/HackathonsList'
import SortByFilter from '../../Components/SortByFilter/SortByFilter'
import styles from './styles.module.scss'
@@ -21,9 +20,6 @@ export default function HackathonsPage() {
tag: Number(tagFilter)
},
})
const { navHeight } = useAppSelector((state) => ({
navHeight: state.ui.navHeight
}));
return (
<>
@@ -35,11 +31,7 @@ export default function HackathonsPage() {
className={`page-container pt-16 w-full ${styles.grid}`}
>
<aside className='no-scrollbar'>
<div className="sticky flex flex-col gap-24 md:overflow-y-scroll"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
<div className="flex flex-col gap-24 md:overflow-y-scroll sticky-side-element">
<h1 id='title' className="text-body1 lg:text-h2 font-bolder">Hackathons 🏆</h1>
<SortByFilter
filterChanged={setSortByFilter}

View File

@@ -2,7 +2,7 @@
import { useUpdateEffect } from '@react-hookz/web'
import { useState } from 'react'
import { useFeedQuery } from 'src/graphql'
import { useAppSelector, useInfiniteQuery, usePreload } from 'src/utils/hooks'
import { useInfiniteQuery, usePreload } from 'src/utils/hooks'
import PostsList from '../../Components/PostsList/PostsList'
import TrendingCard from '../../Components/TrendingCard/TrendingCard'
import PopularTagsFilter, { FilterTag } from './PopularTagsFilter/PopularTagsFilter'
@@ -34,10 +34,6 @@ export default function FeedPage() {
usePreload('PostPage');
const { navHeight, isLoggedIn } = useAppSelector((state) => ({
navHeight: state.ui.navHeight,
isLoggedIn: Boolean(state.user.me),
}));
return (
@@ -76,11 +72,7 @@ export default function FeedPage() {
/>
</div>
<aside id='categories' className='no-scrollbar'>
<div className="sticky md:overflow-y-scroll"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
<div className="pb-16 md:overflow-y-scroll sticky-side-element">
<Button
href='/blog/create-post'
color='primary'
@@ -98,12 +90,7 @@ export default function FeedPage() {
</div>
</aside>
<aside id='side' className='no-scrollbar'>
<div className="sticky flex flex-col gap-24"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
overflowY: "scroll",
}}>
<div className="pb-16 flex flex-col gap-24 overflow-y-auto sticky-side-element" >
<TrendingCard />
<div className='min-h-[300px] text-white flex flex-col justify-end p-24 rounded-12 relative overflow-hidden'
style={{

View File

@@ -4,7 +4,6 @@ import { useParams } from 'react-router-dom'
import NotFoundPage from 'src/features/Shared/pages/NotFoundPage/NotFoundPage'
import { Post_Type, usePostDetailsQuery } from 'src/graphql'
import { capitalize } from 'src/utils/helperFunctions'
import { useAppSelector, } from 'src/utils/hooks'
import { CommentsSection } from '../../Components/Comments'
import ScrollToTop from 'src/utils/routing/scrollToTop'
import TrendingCard from '../../Components/TrendingCard/TrendingCard'
@@ -27,9 +26,6 @@ export default function PostDetailsPage() {
skip: isNaN(Number(id)),
})
const { navHeight } = useAppSelector((state) => ({
navHeight: state.ui.navHeight
}));
if (postDetailsQuery.loading)
return <PostDetailsPageSkeleton />
@@ -50,11 +46,7 @@ export default function PostDetailsPage() {
className={`page-container grid pt-16 w-full gap-32 ${styles.grid}`}
>
<aside id='actions' className='no-scrollbar'>
<div className="sticky"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
<div className="sticky-side-element">
<PostActions post={post} />
</div>
</aside>
@@ -62,12 +54,7 @@ export default function PostDetailsPage() {
<PageContent post={post} />
<aside id='author' className='no-scrollbar min-w-0'>
<div className="flex flex-col gap-24"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
overflowY: "scroll",
}}>
<div className="flex flex-col gap-24 overflow-y-auto sticky-side-element">
<AuthorCard author={post.author} />
<TrendingCard />
</div>

View File

@@ -0,0 +1,79 @@
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
import LoadingPage from "src/Components/LoadingPage/LoadingPage";
import NotFoundPage from "src/features/Shared/pages/NotFoundPage/NotFoundPage";
import { useProfileQuery } from "src/graphql";
import { useAppSelector } from "src/utils/hooks";
import CommentsSettingsCard from "../ProfilePage/CommentsSettingsCard/CommentsSettingsCard";
import UpdateMyProfileCard from "./UpdateMyProfileCard/UpdateMyProfileCard";
import { Helmet } from 'react-helmet'
const links = [
{
text: "👾 My Profile",
path: 'my-profile',
},
{
text: "⚙️ Preferences",
path: 'preferences',
}
]
export default function EditProfilePage() {
const userId = useAppSelector(state => state.user.me?.id)
const profileQuery = useProfileQuery({
variables: {
profileId: userId!,
},
skip: !userId,
})
if (!userId || profileQuery.loading)
return <LoadingPage />
if (!profileQuery.data?.profile)
return <NotFoundPage />
return (
<>
<Helmet>
<title>Settings</title>
<meta property="og:title" content='Settings' />
</Helmet>
<div className="page-container grid grid-cols-1 md:grid-cols-4 gap-24">
<aside>
<div className='bg-white border-2 border-gray-200 rounded-12 p-16 sticky-side-element' >
<p className="text-body2 font-bolder text-black mb-16">Edit maker profile</p>
<ul className=' flex flex-col gap-8'>
{links.map((link, idx) =>
<li key={idx}>
<NavLink
to={link.path}
className={({ isActive }) => `flex items-start rounded-8 cursor-pointer font-bold p-12
active:scale-95 transition-transform
${isActive ? 'bg-gray-100' : 'hover:bg-gray-50'}
`}
>
{link.text}
</NavLink>
</li>)}
</ul>
</div>
</aside>
<main className="md:col-span-2">
<Routes>
<Route index element={<Navigate to='my-profile' />} />
<Route path='my-profile' element={<UpdateMyProfileCard data={profileQuery.data.profile} />} />
<Route path='preferences' element={<CommentsSettingsCard nostr_prv_key={profileQuery.data.profile.nostr_prv_key} nostr_pub_key={profileQuery.data.profile.nostr_pub_key} isOwner={true} />
} />
</Routes>
</main>
</div>
</>
)
}

View File

@@ -0,0 +1,287 @@
import { SubmitHandler, useForm } from "react-hook-form"
import Button from "src/Components/Button/Button";
import { User, useUpdateProfileAboutMutation } from "src/graphql";
import { NotificationsService } from "src/services/notifications.service";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
interface Props {
data: Pick<User,
| 'name'
| 'email'
| 'lightning_address'
| 'jobTitle'
| 'avatar'
| 'website'
| 'github'
| 'twitter'
| 'linkedin'
| 'location'
| 'bio'
>,
onClose?: () => void;
}
type IFormInputs = Props['data'];
const schema: yup.SchemaOf<IFormInputs> = yup.object({
name: yup.string().trim().required().min(2),
avatar: yup.string().url().required(),
bio: yup.string().ensure(),
email: yup.string().email().ensure(),
github: yup.string().ensure(),
jobTitle: yup.string().ensure(),
lightning_address: yup
.string()
.test({
name: "is valid lightning_address",
test: async value => {
try {
if (value) {
const [name, domain] = value.split("@");
const lnurl = `https://${domain}/.well-known/lnurlp/${name}`;
const res = await fetch(lnurl);
if (res.status === 200) return true;
}
return true;
} catch (error) {
return false;
}
}
})
.ensure()
.label("lightning address"),
linkedin: yup.string().ensure(),
location: yup.string().ensure(),
twitter: yup.string().ensure(),
website: yup.string().url().ensure(),
}).required();
export default function UpdateMyProfileCard({ data, onClose }: Props) {
const { register, formState: { errors }, handleSubmit } = useForm<IFormInputs>({
defaultValues: data,
resolver: yupResolver(schema),
mode: 'onBlur',
});
const [mutate, mutationStatus] = useUpdateProfileAboutMutation({
onCompleted: () => {
onClose?.()
}
});
const onSubmit: SubmitHandler<IFormInputs> = data => {
mutate({
variables: {
data: {
name: data.name,
avatar: data.avatar,
jobTitle: data.jobTitle,
bio: data.bio,
email: data.email,
github: data.github,
linkedin: data.linkedin,
lightning_address: data.lightning_address,
location: data.location,
twitter: data.twitter,
website: data.website,
}
}
}).catch(() => {
NotificationsService.error('A network error happened');
mutationStatus.reset()
})
};
return (
<div className="rounded-16 bg-white border-2 border-gray-200">
<div className="bg-gray-600 relative h-[160px] rounded-t-16">
<div className="absolute left-24 bottom-0 translate-y-1/2">
<Avatar src={data.avatar} width={120} />
</div>
</div>
<div className="p-16 md:p-24 mt-64">
<form onSubmit={handleSubmit(onSubmit)}>
<p className="text-body5 font-medium">
Name
</p>
<div className="input-wrapper mt-8 relative">
<input
autoFocus
type='text'
className="input-text"
placeholder='John Doe'
{...register("name")}
/>
</div>
{errors.name && <p className="input-error">
{errors.name.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Avatar
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder='https://images.com/my-avatar.jpg'
{...register("avatar")}
/>
</div>
{errors.avatar && <p className="input-error">
{errors.avatar.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Bio
</p>
<div className="input-wrapper mt-8 relative">
<textarea
rows={3}
className="input-text !p-20"
placeholder='Tell others a little bit about yourself'
{...register("bio")}
/>
</div>
{errors.bio && <p className="input-error">
{errors.bio.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Job Title
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="Back-end Developer"
{...register("jobTitle")}
/>
</div>
{errors.jobTitle && <p className="input-error">
{errors.jobTitle.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Location
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="UK, London"
{...register("location")}
/>
</div>
{errors.location && <p className="input-error">
{errors.location.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Website
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="www.website.io"
{...register("website")}
/>
</div>
{errors.website && <p className="input-error">
{errors.website.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Twitter
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="@johndoe"
{...register("twitter")}
/>
</div>
{errors.twitter && <p className="input-error">
{errors.twitter.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Github
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="johndoe"
{...register("github")}
/>
</div>
{errors.github && <p className="input-error">
{errors.github.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Linkedin
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="www.linkedin.com/in/john-doe"
{...register("linkedin")}
/>
</div>
{errors.linkedin && <p className="input-error">
{errors.linkedin.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Lightning address
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder="johndoe@lnd.com"
{...register("lightning_address")}
/>
</div>
{errors.lightning_address && <p className="input-error">
{errors.lightning_address.message}
</p>}
<p className="text-body6 text-gray-400 mt-8 max-w-[70ch]">
Your lightning address is used to send the votes you get on your posts, comments, apps...etc, directly to you.
</p>
<div className="mt-24 flex gap-16 justify-end">
<Button
color='gray'
disabled={mutationStatus.loading}
onClick={onClose}
>
Cancel
</Button>
<Button
type='submit'
color='primary'
isLoading={mutationStatus.loading}
disabled={mutationStatus.loading}
>
Save changes
</Button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -2,7 +2,8 @@
$screen-xs-min: 320px;
@import "./tw.scss", "./shared.scss", "./vendors.scss", "./scrollbar.scss";
@import "./tw.scss", "./shared.scss", "./vendors.scss", "./scrollbar.scss",
"./ui_state.scss";
@import "/src/styles/mixins/index.scss";
html {

View File

@@ -1,15 +1,21 @@
.input-removed-arrows::-webkit-outer-spin-button,
.input-removed-arrows::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
.input-removed-arrows[type="number"] {
-moz-appearance: textfield;
-moz-appearance: textfield;
}
button[disabled]{
opacity: .5;
pointer-events: none;
}
button[disabled] {
opacity: 0.5;
pointer-events: none;
}
.sticky-side-element {
position: sticky;
top: calc(var(--navHeight) + 16px);
max-height: calc(100vh - var(--navHeight) - 16px);
}

3
src/styles/ui_state.scss Normal file
View File

@@ -0,0 +1,3 @@
:root {
--navHeight: 0;
}

View File

@@ -1,5 +1,5 @@
import { useDebouncedCallback } from "@react-hookz/web";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
export const useResizeListener = (
listener: () => void,
@@ -7,7 +7,7 @@ export const useResizeListener = (
) => {
options.debounce = options.debounce ?? 250;
const func = useDebouncedCallback(listener, [], options.debounce)
const func = useDebouncedCallback(listener, [listener], options.debounce)
useEffect(() => {
window.addEventListener("resize", func);

View File

@@ -78,6 +78,7 @@ export const PAGES_ROUTES = {
default: "/donate"
},
profile: {
editProfile: "/edit-profile/*",
byId: "/profile/:id/*",
},
auth: {