mirror of
https://github.com/hexolan/panels.git
synced 2026-03-26 20:41:15 +00:00
init frontend
This commit is contained in:
51
services/frontend/src/components/AppHeader.tsx
Normal file
51
services/frontend/src/components/AppHeader.tsx
Normal 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
|
||||
27
services/frontend/src/components/AppLayout.tsx
Normal file
27
services/frontend/src/components/AppLayout.tsx
Normal 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
|
||||
58
services/frontend/src/components/AppNavbar.tsx
Normal file
58
services/frontend/src/components/AppNavbar.tsx
Normal 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
|
||||
14
services/frontend/src/components/CommentsFeed.tsx
Normal file
14
services/frontend/src/components/CommentsFeed.tsx
Normal 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
|
||||
63
services/frontend/src/components/CreateComment.tsx
Normal file
63
services/frontend/src/components/CreateComment.tsx
Normal 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
|
||||
153
services/frontend/src/components/FeedComment.tsx
Normal file
153
services/frontend/src/components/FeedComment.tsx
Normal 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
|
||||
79
services/frontend/src/components/FeedPost.tsx
Normal file
79
services/frontend/src/components/FeedPost.tsx
Normal 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
|
||||
27
services/frontend/src/components/HomePostFeed.tsx
Normal file
27
services/frontend/src/components/HomePostFeed.tsx
Normal 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
|
||||
5
services/frontend/src/components/LoadingBar.tsx
Normal file
5
services/frontend/src/components/LoadingBar.tsx
Normal 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
|
||||
153
services/frontend/src/components/PagePost.tsx
Normal file
153
services/frontend/src/components/PagePost.tsx
Normal 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
|
||||
81
services/frontend/src/components/PanelLayout.tsx
Normal file
81
services/frontend/src/components/PanelLayout.tsx
Normal 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
|
||||
28
services/frontend/src/components/PanelPostFeed.tsx
Normal file
28
services/frontend/src/components/PanelPostFeed.tsx
Normal 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
|
||||
24
services/frontend/src/components/PostCommentsFeed.tsx
Normal file
24
services/frontend/src/components/PostCommentsFeed.tsx
Normal 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
|
||||
19
services/frontend/src/components/SkeletonFeedPost.tsx
Normal file
19
services/frontend/src/components/SkeletonFeedPost.tsx
Normal 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
|
||||
5
services/frontend/src/components/SkeletonPostFeed.tsx
Normal file
5
services/frontend/src/components/SkeletonPostFeed.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SkeletonFeedPost from './SkeletonFeedPost'
|
||||
|
||||
const SkeletonPostFeed = () => [...Array(10)].map(() => <SkeletonFeedPost />)
|
||||
|
||||
export default SkeletonPostFeed
|
||||
86
services/frontend/src/components/UserLayout.tsx
Normal file
86
services/frontend/src/components/UserLayout.tsx
Normal 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
|
||||
28
services/frontend/src/components/UserPostFeed.tsx
Normal file
28
services/frontend/src/components/UserPostFeed.tsx
Normal 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
|
||||
Reference in New Issue
Block a user