init frontend

This commit is contained in:
2023-09-27 20:27:25 +01:00
parent 4aa5cd6dfc
commit 9e6659c14e
63 changed files with 4901 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { Link } from 'react-router-dom'
import { Header, Flex, Button, Group, Avatar, Text, Menu } from '@mantine/core'
import { IconChevronDown, IconUserEdit, IconLogout } from '@tabler/icons-react'
import panelsLogo from '../assets/logo.svg'
import { useAppSelector, useAppDispatch } from '../app/hooks'
import { setUnauthed } from '../app/features/auth'
function AppHeader() {
const currentUser = useAppSelector((state) => state.auth.currentUser)
const dispatch = useAppDispatch();
const signoutUser = () => {
dispatch(setUnauthed())
}
return (
<Header height={60} p={20}>
<Flex justify='space-between' align='center' h='100%'>
<Link to='/'>
<img src={panelsLogo} height={30} alt='Panels Logo' />
</Link>
{!currentUser ? (
<Button color='teal' component={Link} to='/signin'>Sign In</Button>
) : (
<Menu>
<Menu.Target>
<Button color='teal' variant='outline'>
<Group spacing={7}>
<Avatar color='teal' radius='xl' size={25} />
<Text weight={500} size='sm' sx={{ lineHeight: 1 }} mr={3}>
{currentUser.username}
</Text>
<IconChevronDown size={20} />
</Group>
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>User Actions</Menu.Label>
<Menu.Item icon={<IconUserEdit />} component={Link} to={'/user/' + currentUser.username}>My Profile</Menu.Item>
<Menu.Item color='red' icon={<IconLogout />} onClick={signoutUser}>Sign Out</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Flex>
</Header>
)
}
export default AppHeader

View File

@@ -0,0 +1,27 @@
import { ReactNode, Suspense } from 'react'
import { AppShell } from '@mantine/core'
import { Outlet } from 'react-router-dom'
import AppNavbar from './AppNavbar'
import AppHeader from './AppHeader'
import LoadingBar from './LoadingBar'
interface AppLayoutProps {
children?: ReactNode;
}
function AppLayout(props: AppLayoutProps) {
return (
<AppShell
navbar={<AppNavbar />}
header={<AppHeader />}
padding={0}
>
<Suspense fallback={<LoadingBar />}>
{props?.children ? props.children : <Outlet /> }
</Suspense>
</AppShell>
);
}
export default AppLayout

View File

@@ -0,0 +1,58 @@
import { createElement } from 'react'
import { NavLink } from 'react-router-dom'
import { Navbar, ThemeIcon, Group, Text, UnstyledButton, rem } from '@mantine/core'
import { IconTrendingUp, IconSearch, IconMessages } from '@tabler/icons-react'
const NavbarButton = ({ text, page, icon }: { text: string, page: string, icon: JSX.ElementType }) => (
<NavLink to={page} style={{ textDecoration: 'none' }}>
{({ isActive }) => (
<UnstyledButton
p='xs'
sx={(theme) => ({
display: 'block',
width: '100%',
borderRadius: theme.radius.sm,
backgroundColor: isActive ? theme.colors.gray[0] : 'inherit',
'&:hover': {
backgroundColor: theme.colors.gray[0],
},
})}
>
<Group>
<ThemeIcon color='teal' variant='light'>
{ createElement(icon, { size: '1rem' }) }
</ThemeIcon>
<Text size='sm'>{text}</Text>
</Group>
</UnstyledButton>
)}
</NavLink>
)
function AppNavbar() {
return (
<Navbar width={{ base: 300 }} p='xs'>
<Navbar.Section py='xs'>
<Text size='xs' color='dimmed' my='xs' weight={500}>Browse</Text>
<NavbarButton text='Feed' page='/' icon={IconTrendingUp} />
<NavbarButton text='Find Panels' page='/panels' icon={IconSearch} />
</Navbar.Section>
<Navbar.Section
grow
pt='xs'
sx={(theme) => ({
borderTop: `${rem(1)} solid ${theme.colors.gray[3]}`
})}
>
<Text size='xs' color='dimmed' m='xs' weight={500}>Suggested Panels</Text>
<NavbarButton text='panel/Panel' page='/panel/Panel' icon={IconMessages} />
</Navbar.Section>
</Navbar>
)
}
export default AppNavbar

View File

@@ -0,0 +1,14 @@
import { Stack } from '@mantine/core'
import FeedComment from './FeedComment'
import type { Comment } from '../app/types/common'
function CommentsFeed({ comments }: { comments: Comment[] }) {
return (
<Stack spacing='sm'>
{Object.values(comments).map(comment => <FeedComment key={comment.id} comment={comment} />)}
</Stack>
)
}
export default CommentsFeed

View File

@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Paper, Flex, Textarea, ActionIcon } from '@mantine/core'
import { useForm, hasLength } from '@mantine/form'
import { IconWriting } from '@tabler/icons-react'
import { useCreatePostCommentMutation } from '../app/api/comments'
import type { Comment, Post } from '../app/types/common'
import type { CreateCommentData } from '../app/types/comments'
const CreateComment = ({ post, addNewComment }: { post: Post, addNewComment: (comment: Comment) => void }) => {
const [errorMsg, setErrorMsg] = useState('')
const [createComment, { isLoading }] = useCreatePostCommentMutation()
const submitComment = async (values: CreateCommentData) => {
await createComment({
postId: post.id,
data: values
}).unwrap().then((comment) => {
// Display the new comment
addNewComment(comment)
setErrorMsg('')
}).catch((error) => {
if (!error.data) {
setErrorMsg('Failed to access the API')
} else {
setErrorMsg(error.data.msg)
}
})
}
const commentForm = useForm<CreateCommentData>({
initialValues: {
message: '',
},
validate: {
message: hasLength({ min: 3, max: 512 }, 'Message must be between 3 and 512 characters'),
}
})
return (
<Paper shadow='sm' radius='md' p='md' withBorder>
<form onSubmit={commentForm.onSubmit(submitComment)}>
<Flex gap='sm' align='center' direction='row' wrap='nowrap'>
<Textarea
size='xs'
w='100%'
radius='lg'
variant='filled'
placeholder='Input comment...'
error={errorMsg}
{...commentForm.getInputProps('message')}
/>
<ActionIcon type='submit' radius='lg' color='teal' variant='outline' size='xl' aria-label='Post Comment' disabled={isLoading}>
<IconWriting />
</ActionIcon>
</Flex>
</form>
</Paper>
)
}
export default CreateComment

View File

@@ -0,0 +1,153 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useForm, hasLength } from '@mantine/form'
import { Paper, Group, Box, ThemeIcon, Text, ActionIcon, Menu, Textarea, Flex } from '@mantine/core'
import { IconMessage, IconMenu2, IconTrash, IconPencil, IconPencilCancel } from '@tabler/icons-react'
import { useAppSelector } from '../app/hooks'
import { useGetUserByIdQuery } from '../app/api/users'
import { useDeletePostCommentMutation, useUpdatePostCommentMutation } from '../app/api/comments'
import type { Comment } from '../app/types/common'
import type { UpdateCommentData } from '../app/types/comments'
const FeedCommentBase = ({ children, extraChildren }: { children: React.ReactNode, extraChildren?: React.ReactNode }) => (
<Paper shadow='sm' radius='md' p='md' withBorder>
<Group w='100%' position='apart'>
<Group>
<ThemeIcon color='teal' variant='light' size='xl'><IconMessage /></ThemeIcon>
{children}
</Group>
{extraChildren}
</Group>
</Paper>
)
const StandardFeedComment = ({ comment, authorElement }: { comment: Comment, authorElement: React.ReactNode }) => (
<FeedCommentBase>
<Box>
<Text size='sm'>{comment.message}</Text>
{authorElement}
</Box>
</FeedCommentBase>
)
const ModifiableFeedComment = ({
comment,
authorElement,
setSelf,
isAuthor
}: {
comment: Comment,
authorElement: React.ReactNode,
setSelf: React.Dispatch<Comment | undefined>,
isAuthor: boolean
}) => {
const [modifying, setModifying] = useState<boolean>(false)
const [errorMsg, setErrorMsg] = useState('')
const commentForm = useForm<UpdateCommentData>({
initialValues: {
message: comment.message,
},
validate: {
message: hasLength({ min: 3, max: 512 }, 'Message must be between 3 and 512 characters'),
}
})
const [updateComment, { isLoading }] = useUpdatePostCommentMutation()
const submitUpdateComment = async (values: UpdateCommentData) => {
await updateComment({
id: comment.id,
postId: comment.postId,
data: values
}).unwrap().then((commentInfo) => {
setSelf(commentInfo)
setModifying(false)
}).catch((error) => {
if (!error.data) {
setErrorMsg('Failed to access the API')
} else {
setErrorMsg(error.data.msg)
}
})
}
const [deleteComment] = useDeletePostCommentMutation()
const submitDeleteComment = async () => {
await deleteComment({
id: comment.id,
postId: comment.postId
}).unwrap().then(() => {
setSelf(undefined)
}).catch((error) => {
if (!error.data) {
setErrorMsg('Failed to access the API')
} else {
setErrorMsg(error.data.msg)
}
})
}
return (
<FeedCommentBase
extraChildren={
<Menu>
<Menu.Target>
<ActionIcon color='teal' variant='light' radius='xl' size='xl'><IconMenu2 /></ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Comment Options</Menu.Label>
{isAuthor && (modifying
? <Menu.Item icon={<IconPencilCancel size={14} />} onClick={() => setModifying(false)}>Stop Modifying</Menu.Item>
: <Menu.Item icon={<IconPencil size={14} />} onClick={() => setModifying(true)}>Modify</Menu.Item>
)}
<Menu.Item color='red' icon={<IconTrash size={14} />} onClick={() => submitDeleteComment()}>Delete</Menu.Item>
</Menu.Dropdown>
</Menu>
}
>
{modifying ? (
<form onSubmit={commentForm.onSubmit(submitUpdateComment)}>
<Flex>
<Textarea size='xs' w='100%' radius='lg' variant='filled' error={errorMsg} {...commentForm.getInputProps('message')} />
<ActionIcon type='submit' radius='lg' color='teal' variant='outline' size='xl' aria-label='Update Comment' disabled={isLoading}>
<IconPencil />
</ActionIcon>
</Flex>
</form>
) : (
<Box>
<Text size='sm'>{comment.message}</Text>
{authorElement}
</Box>
)}
</FeedCommentBase>
)
}
const FeedCommentItem = ({ comment, setSelf }: { comment: Comment, setSelf: React.Dispatch<Comment | undefined> }) => {
const currentUser = useAppSelector((state) => state.auth.currentUser)
// fetching comment author info
const { data, isLoading } = useGetUserByIdQuery({ id: comment.authorId })
let authorElement = null
if (isLoading) {
authorElement = <Text color='dimmed' size='xs'>Loading Author Info...</Text>
} else if (!data) {
authorElement = <Text color='red' size='xs'>Failed to load Author Info</Text>
} else {
authorElement = <Text color='dimmed' size='xs' mt={3} component={Link} to={`/user/${data.username}`}>by user/{data.username}</Text>
}
if (currentUser && (currentUser.id == comment.authorId || currentUser.isAdmin)) {
return <ModifiableFeedComment comment={comment} authorElement={authorElement} isAuthor={currentUser.id == comment.authorId} setSelf={setSelf} />
} else {
return <StandardFeedComment comment={comment} authorElement={authorElement} />
}
}
const FeedComment = ({ comment: initialComment }: { comment: Comment }) => {
const [comment, setComment] = useState<Comment | undefined>(initialComment)
return comment ? <FeedCommentItem comment={comment} setSelf={setComment} /> : null
}
export default FeedComment

View File

@@ -0,0 +1,79 @@
import { Link } from 'react-router-dom'
import { Paper, Skeleton, Box, Badge, Text, Group, ThemeIcon } from '@mantine/core'
import { IconUser, IconMessages } from '@tabler/icons-react'
import { useGetUserByIdQuery } from '../app/api/users'
import { useGetPanelByIdQuery } from '../app/api/panels'
import type { Post } from '../app/types/common'
const FeedPost = ({ post, hidePanel, hideAuthor }: { post: Post, hidePanel?: boolean, hideAuthor?: boolean }) => {
// Fetch panel info
let panelElement: React.ReactNode = null
const { data: panelData, isLoading: panelIsLoading } = useGetPanelByIdQuery({ id: post.panelId })
if (!hidePanel) {
if (panelIsLoading) {
panelElement = <Skeleton height={8} mt={6} width='20%' radius='xl' />
} else if (!panelData) {
panelElement = <Text color='red' size='xs'>Error Loading Panel Data</Text>
} else {
panelElement = (
<Badge
pl={0}
color='orange'
leftSection={
<ThemeIcon color='orange' size={24} radius='xl' mr={5}>
<IconMessages size={12} />
</ThemeIcon>
}
component={Link}
to={`/panel/${panelData.name}`}
>
{`panel/${panelData.name}`}
</Badge>
)
}
}
// Fetch author info
let authorElement: React.ReactNode = null
const { data: authorData, isLoading: authorIsLoading } = useGetUserByIdQuery({ id: post.authorId })
if (!hideAuthor) {
if (authorIsLoading) {
authorElement = <Skeleton height={8} mt={6} width='20%' radius='xl' />
} else if (!authorData) {
authorElement = <Text color='red' size='xs'>Error Loading Author Data</Text>
} else {
authorElement = (
<Badge
pl={0}
color='teal'
leftSection={
<ThemeIcon color='teal' size={24} radius='xl' mr={5}>
<IconUser size={12} />
</ThemeIcon>
}
component={Link}
to={`/user/${authorData.username}`}
>
{`user/${authorData.username}`}
</Badge>
)
}
}
return (
<Paper shadow='xl' radius='lg' p='lg' withBorder>
<Group spacing='xs'>
{panelElement}
{authorElement}
</Group>
<Box component={Link} to={panelData ? `/panel/${panelData.name}/post/${post.id}` : '#'} style={{ textDecoration: 'none', color: 'inherit' }}>
<Text mt={3} weight={600} lineClamp={1}>{post.title}</Text>
<Text size='sm' lineClamp={2}>{post.content}</Text>
<Text size='xs' color='dimmed' mt={3}>Click to View</Text>
</Box>
</Paper>
)
}
export default FeedPost

View File

@@ -0,0 +1,27 @@
import { Text } from '@mantine/core'
import FeedPost from './FeedPost'
import SkeletonPostFeed from './SkeletonPostFeed'
import { useGetFeedPostsQuery } from '../app/api/posts'
function HomePostFeed() {
const { data, isLoading } = useGetFeedPostsQuery()
if (isLoading) {
return <SkeletonPostFeed />
} else if (!data) {
return <Text align='center' color='red'>Failed to Load Posts</Text>
} else if (!data.length) {
// Check that there are posts.
return <Text align='center'>No Posts Found!</Text>
}
return (
<>
{Object.values(data).map(post => {
return <FeedPost key={post.id} post={post} />
})}
</>
)
}
export default HomePostFeed

View File

@@ -0,0 +1,5 @@
import { Progress } from '@mantine/core'
const LoadingBar = () => <Progress color='lime' radius='xs' size='sm' value={100} striped animate />
export default LoadingBar

View File

@@ -0,0 +1,153 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Paper, Stack, Badge, ThemeIcon, Text, Group, Menu, ActionIcon, TextInput, Textarea, Button } from '@mantine/core'
import { useForm, hasLength } from '@mantine/form'
import { IconUser, IconMenu2, IconPencil, IconTrash } from '@tabler/icons-react'
import { useAppSelector } from '../app/hooks'
import { useGetUserByIdQuery } from '../app/api/users'
import { useDeletePostMutation, useUpdatePostMutation } from '../app/api/posts'
import type { Post } from '../app/types/common'
import type { UpdatePostData } from '../app/types/posts'
const ModifyPostForm = ({
post,
setPost,
setModifying
}: {
post: Post,
setPost: React.Dispatch<Post>,
setModifying: React.Dispatch<boolean>
}) => {
const [errorMsg, setErrorMsg] = useState('')
const updatePostForm = useForm<UpdatePostData>({
initialValues: {
title: post.title,
content: post.content,
},
validate: {
title: hasLength({ min: 3, max: 512 }, 'Title must be between 3 and 512 characters'),
content: hasLength({ min: 3, max: 2048 }, 'Content must be between 3 and 2048 characters'),
}
})
const [updatePost, { isLoading }] = useUpdatePostMutation()
const submitUpdatePost = async (values: UpdatePostData) => {
await updatePost({
id: post.id,
data: values
}).unwrap().then((postInfo) => {
setErrorMsg('')
setPost(postInfo)
setModifying(false)
}).catch((error) => {
if (!error.data) {
setErrorMsg('Failed to access the API')
} else {
setErrorMsg(error.data.msg)
}
})
}
return (
<form onSubmit={updatePostForm.onSubmit(submitUpdatePost)}>
<Stack spacing='sm'>
<TextInput
label='Title'
placeholder='Post Title'
{...updatePostForm.getInputProps('title')}
/>
<Textarea
label='Content'
placeholder='Post Content'
{...updatePostForm.getInputProps('content')}
/>
{errorMsg && <Text color='red' align='center'>{'Error: ' + errorMsg}</Text>}
<Button type='submit' variant='outline' color='teal' disabled={isLoading} fullWidth>
Update Post
</Button>
</Stack>
</form>
)
}
const PagePostItem = ({ post, setPost }: { post: Post, setPost: React.Dispatch<Post> }) => {
const navigate = useNavigate()
const [modifying, setModifying] = useState<boolean>(false)
const currentUser = useAppSelector((state) => state.auth.currentUser)
const [deletePost] = useDeletePostMutation()
const [errorMsg, setErrorMsg] = useState('')
const submitDeletePost = async () => {
await deletePost({ id: post.id }).unwrap().then(() => {
navigate('/')
}).catch((error) => {
if (!error.data) {
setErrorMsg('Failed to access the API')
} else {
setErrorMsg(error.data.msg)
}
})
}
const { data: authorData } = useGetUserByIdQuery({ id: post.authorId })
return (
<Paper shadow='lg' radius='lg' p='lg' withBorder>
{authorData && (
<Group position='apart'>
<Badge
pl={0}
color='teal'
leftSection={
<ThemeIcon color='teal' size={24} radius='xl' mr={5}>
<IconUser size={12} />
</ThemeIcon>
}
component={Link}
to={`/user/${authorData.username}`}
>
{`user/${authorData.username}`}
</Badge>
{(currentUser && (currentUser.id == post.authorId || currentUser.isAdmin)) && (
<Menu>
<Menu.Target>
<ActionIcon color='teal' variant='light' radius='xl' size={24}>
<IconMenu2 size={12} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Post Options</Menu.Label>
{ currentUser.id == post.authorId && (
modifying ? <Menu.Item icon={<IconPencil size={14} />} onClick={() => setModifying(false)}>Stop Modifying</Menu.Item>
: <Menu.Item icon={<IconPencil size={14} />} onClick={() => setModifying(true)}>Modify</Menu.Item>
)}
<Menu.Item color='red' icon={<IconTrash size={14} />} onClick={() => submitDeletePost()}>Delete</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
)}
{modifying ? <ModifyPostForm post={post} setModifying={setModifying} setPost={setPost} /> : (
<Stack align='flex-start' mt={2} spacing={1}>
<Text weight={600}>{post.title}</Text>
<Text size='sm'>{post.content}</Text>
<Text size='xs' color='dimmed' mt={3}>Created {post.createdAt}</Text>
</Stack>
)}
{errorMsg && <Text color='red' align='center' size='xs' mt='md'>{'Error: ' + errorMsg}</Text>}
</Paper>
)
}
const PagePost = ({ post: initialPost }: { post: Post }) => {
const [post, setPost] = useState<Post>(initialPost)
return <PagePostItem post={post} setPost={setPost} />
}
export default PagePost

View File

@@ -0,0 +1,81 @@
import { Suspense, useState } from 'react'
import { Link, Outlet, useParams } from 'react-router-dom'
import { Paper, Container, Group, Box, Text, Button, rem } from '@mantine/core'
import LoadingBar from '../components/LoadingBar'
import { useAppSelector } from '../app/hooks'
import { useGetPanelByNameQuery } from '../app/api/panels'
import type { Panel } from '../app/types/common'
import type { ErrorResponse } from '../app/types/api'
export type PanelContext = {
panel: Panel;
setPanel: React.Dispatch<Panel>;
}
type PanelParams = {
panelName: string
}
const PanelLayoutComponent = ({ panel, setPanel }: { panel: Panel, setPanel: React.Dispatch<Panel> }) => {
const currentUser = useAppSelector((state) => state.auth.currentUser)
return (
<>
<Paper py={rem(50)} shadow='md' sx={{ borderBottom: '1px' }}>
<Container>
<Group position='apart'>
<Box component={Link} to={`/panel/${panel.name}`} style={{ textDecoration: 'none' }}>
<Text size='lg' color='black'>{panel.name}</Text>
<Text size='sm' color='dimmed'>{panel.description}</Text>
</Box>
<Group spacing='sm'>
{currentUser && <Button size='xs' variant='filled' color='teal' component={Link} to={`/panel/${panel.name}/posts/new`}>Create Post</Button>}
{currentUser && currentUser.isAdmin && <Button size='xs' variant='outline' color='green' component={Link} to={`/panel/${panel.name}/settings`}>Manage Panel</Button>}
</Group>
</Group>
</Container>
</Paper>
<Container mt='xl'>
<Suspense>
<Outlet context={{ panel: panel, setPanel: setPanel } satisfies PanelContext} />
</Suspense>
</Container>
</>
)
}
const PanelLayoutComponentWrapper = ({ panel: initialPanel }: { panel: Panel }) => {
const [panel, setPanel] = useState<Panel>(initialPanel)
return <PanelLayoutComponent panel={panel} setPanel={setPanel} />
}
function PanelLayout() {
const { panelName } = useParams<PanelParams>();
if (panelName === undefined) {
throw Error('panel name not provided')
}
const { data, error, isLoading } = useGetPanelByNameQuery({ name: panelName })
if (isLoading) {
return <LoadingBar />;
} else if (!data) {
if (!error) {
throw Error('Unknown error occured')
} else if ('data' in error) {
const errResponse = error.data as ErrorResponse
if (errResponse.msg) {
throw Error(errResponse.msg)
} else {
throw Error('Unexpected API error occured')
}
} else {
throw Error('Failed to access the API')
}
}
return <PanelLayoutComponentWrapper panel={data} />
}
export default PanelLayout

View File

@@ -0,0 +1,28 @@
import { Text } from '@mantine/core'
import FeedPost from './FeedPost'
import SkeletonPostFeed from './SkeletonPostFeed'
import { useGetPanelPostsQuery } from '../app/api/posts'
import type { Panel } from '../app/types/common'
function PanelPostFeed({ panel }: { panel: Panel }) {
const { data, isLoading } = useGetPanelPostsQuery({ panelId: panel.id })
if (isLoading) {
return <SkeletonPostFeed />
} else if (!data) {
return <Text align='center'>Failed to Load Posts</Text>
} else if (!data.length) {
// Check that there are posts.
return <Text align='center'>No Posts Found!</Text>
}
return (
<>
{Object.values(data).map(post => {
return <FeedPost key={post.id} post={post} hidePanel={true} />
})}
</>
)
}
export default PanelPostFeed

View File

@@ -0,0 +1,24 @@
import { Center, Loader, Text } from '@mantine/core'
import CommentsFeed from './CommentsFeed'
import { useGetPostCommentsQuery } from '../app/api/comments'
import type { Post } from '../app/types/common'
function PostCommentsFeed({ post }: { post: Post }) {
const { data, isLoading } = useGetPostCommentsQuery({ postId: post.id })
if (isLoading) {
return (
<Center>
<Loader color='dark' size='sm' />
</Center>
)
} else if (!data) {
return <Text color='red' align='center'>Failed to Load Comments</Text>
} else if (!data.length) {
return null
}
return <CommentsFeed comments={data} />
}
export default PostCommentsFeed

View File

@@ -0,0 +1,19 @@
import { Paper, Skeleton, Box, Stack, Group } from '@mantine/core'
const SkeletonFeedPost = () => (
<Paper shadow='xl' radius='lg' p='lg' withBorder>
<Group spacing='xs'>
<Skeleton height={8} mt={6} width='20%' radius='xl' />
<Skeleton height={8} mt={6} width='20%' radius='xl' />
</Group>
<Stack mt={2} spacing={1}>
<Box>
<Skeleton h='md' radius='xl' w='60%' />
<Skeleton h='md' radius='xl' mt='sm' />
<Skeleton h='xs' radius='xl' mt='sm' w='20%' />
</Box>
</Stack>
</Paper>
)
export default SkeletonFeedPost

View File

@@ -0,0 +1,5 @@
import SkeletonFeedPost from './SkeletonFeedPost'
const SkeletonPostFeed = () => [...Array(10)].map(() => <SkeletonFeedPost />)
export default SkeletonPostFeed

View File

@@ -0,0 +1,86 @@
import { Suspense } from 'react'
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
import { Container, Box, Center, Paper, Flex, Avatar, Text, Tabs } from '@mantine/core'
import { IconMessageCircle, IconAddressBook, IconSettings } from '@tabler/icons-react'
import LoadingBar from './LoadingBar'
import { User } from '../app/types/common'
import { useAppSelector } from '../app/hooks'
import { useGetUserByNameQuery } from '../app/api/users'
import type { ErrorResponse } from '../app/types/api'
export type UserContext = {
user: User
}
type UserPageParams = {
username: string;
}
function UserLayout() {
const navigate = useNavigate()
const currentUser = useAppSelector((state) => state.auth.currentUser)
const { pathname } = useLocation()
let currentTab = 'posts'
if (pathname.endsWith('about') || pathname.endsWith('about/')) {
currentTab = 'about'
} else if (pathname.endsWith('settings') || pathname.endsWith('settings/')) {
currentTab = 'settings'
}
const { username } = useParams<UserPageParams>();
if (username === undefined) {
throw Error('Username not provided')
}
const { data, error, isLoading } = useGetUserByNameQuery({ username: username })
if (isLoading) {
return <LoadingBar />;
} else if (!data) {
if (!error) {
throw Error('Unknown error occured')
} else if ('data' in error) {
const errResponse = error.data as ErrorResponse
if (errResponse.msg) {
throw Error(errResponse.msg)
} else {
throw Error('Unexpected API error occured')
}
} else {
throw Error('Failed to access the API')
}
}
return (
<Container mt='xl'>
<Tabs color='teal' radius='md' value={currentTab} onTabChange={(tab) => navigate(`/user/${data.username}${tab === 'posts' ? '' : `/${tab}`}`)}>
<Paper shadow='md' radius='md' mt='md' withBorder>
<Flex>
<Avatar radius='md' size={200} color='lime' />
<Paper w='100%'>
<Box h='100%' pos='relative'>
<Center h='100%'>
<Text weight={600} mr={3}>User:</Text>
<Text>{data.username}</Text>
</Center>
<Tabs.List pos='absolute' bottom={0} w='100%' grow>
<Tabs.Tab value='posts' icon={<IconMessageCircle size='0.8rem' />}>Posts</Tabs.Tab>
<Tabs.Tab value='about' icon={<IconAddressBook size='0.8rem' />}>About</Tabs.Tab>
{(currentUser && (currentUser.id == data.id || currentUser.isAdmin)) && <Tabs.Tab value='settings' icon={<IconSettings size='0.8rem' />}>Settings</Tabs.Tab>}
</Tabs.List>
</Box>
</Paper>
</Flex>
</Paper>
</Tabs>
<Suspense>
<Outlet context={{ user: data } satisfies UserContext} />
</Suspense>
</Container>
)
}
export default UserLayout

View File

@@ -0,0 +1,28 @@
import { Text } from '@mantine/core'
import FeedPost from './FeedPost'
import SkeletonPostFeed from './SkeletonPostFeed'
import { useGetUserPostsQuery } from '../app/api/posts'
import type { User } from '../app/types/common'
function UserPostFeed({ user }: { user: User }) {
const { data, isLoading } = useGetUserPostsQuery({ userId: user.id })
if (isLoading) {
return <SkeletonPostFeed />
} else if (!data) {
return <Text align='center' color='red'>Failed to Load Posts</Text>
} else if (!data.length) {
// Check that there are posts.
return <Text align='center'>No Posts Found!</Text>
}
return (
<>
{Object.values(data).map(post => {
return <FeedPost key={post.id} post={post} hideAuthor={true} />
})}
</>
)
}
export default UserPostFeed