mirror of
https://github.com/hexolan/panels.git
synced 2026-03-26 12:40:21 +00:00
init frontend
This commit is contained in:
2
services/frontend/.dockerignore
Normal file
2
services/frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
1
services/frontend/.env.example
Normal file
1
services/frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
18
services/frontend/.eslintrc.cjs
Normal file
18
services/frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
20
services/frontend/Dockerfile
Normal file
20
services/frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:18 as build
|
||||
WORKDIR /app
|
||||
|
||||
# Build options
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL $VITE_API_URL
|
||||
|
||||
# Install requirements
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
|
||||
# Copy files and build
|
||||
COPY . .
|
||||
RUN yarn run build
|
||||
|
||||
# Serve the frontend
|
||||
FROM nginx:alpine-slim
|
||||
EXPOSE 80
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY --from=build /app/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
9
services/frontend/README.md
Normal file
9
services/frontend/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Frontend
|
||||
|
||||
## Configuration
|
||||
|
||||
### Build Environment Variables
|
||||
|
||||
``VITE_API_URL`` (Default: "`http://localhost:3000`")
|
||||
|
||||
* e.g. "`http://localhost:3000`" or "`http://gateway-service.linkto.local`", etc
|
||||
13
services/frontend/index.html
Normal file
13
services/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Panels</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
services/frontend/nginx.conf
Normal file
9
services/frontend/nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
}
|
||||
42
services/frontend/package.json
Normal file
42
services/frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"description": "Panels - Frontend Site",
|
||||
"version": "1.0.0",
|
||||
"author": "Declan <declan@hexolan.dev>",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@mantine/core": "^6.0.19",
|
||||
"@mantine/form": "^6.0.20",
|
||||
"@mantine/hooks": "^6.0.19",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@tabler/icons-react": "^2.32.0",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"sort-by": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
},
|
||||
"homepage": "https://github.com/hexolan/panels"
|
||||
}
|
||||
1
services/frontend/public/icon.svg
Normal file
1
services/frontend/public/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="125" height="125" viewBox="0 0 33.073 33.073"><defs><linearGradient id="A" x1=".135" y1="45.069" x2="124.158" y2="45.069" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fd9245"/><stop offset="1" stop-color="#06a96b"/></linearGradient></defs><g transform="matrix(.271151 0 0 .260352 -.316055 4.802675)" fill="none" stroke-width="11.72" stroke-linecap="round" stroke-linejoin="round"><path d="M 62.146818,5.1975622 7.025606,25.133099 62.146818,45.068637 117.26803,25.133099 62.146818,5.1975622" stroke="url(#A)"/><path d="M 7.025606,45.068637 62.146818,65.004174 117.26803,45.068637" stroke="url(#A)"/><path d="M 7.025606,65.004174 62.146818,84.939711 117.26803,65.004174" stroke="url(#A)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 760 B |
110
services/frontend/src/App.tsx
Normal file
110
services/frontend/src/App.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { lazy } from 'react'
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
|
||||
|
||||
import AppLayout from './components/AppLayout'
|
||||
import LoadingBar from './components/LoadingBar'
|
||||
import ErrorPage from './pages/Error'
|
||||
|
||||
const Homepage = lazy(() => import('./pages/Home'))
|
||||
|
||||
const SignInPage = lazy(() => import('./pages/SignIn'))
|
||||
const SignUpPage = lazy(() => import('./pages/SignUp'))
|
||||
|
||||
const UserLayout = lazy(() => import('./components/UserLayout'))
|
||||
const UserPage = lazy(() => import('./pages/User'))
|
||||
const UserAboutPage = lazy(() => import('./pages/UserAbout'))
|
||||
const UserSettingsPage = lazy(() => import('./pages/UserSettings'))
|
||||
|
||||
const ExplorePanelsPage = lazy(() => import('./pages/ExplorePanels'))
|
||||
const NewPanelPage = lazy(() => import('./pages/NewPanel'))
|
||||
|
||||
const PanelLayout = lazy(() => import('./components/PanelLayout'))
|
||||
const PanelPage = lazy(() => import('./pages/Panel'))
|
||||
const PanelSettingsPage = lazy(() => import('./pages/PanelSettings'))
|
||||
const PanelPostPage = lazy(() => import('./pages/PanelPost'))
|
||||
const NewPanelPostPage = lazy(() => import('./pages/NewPanelPost'))
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <AppLayout />,
|
||||
errorElement: <AppLayout><ErrorPage /></AppLayout>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Homepage />,
|
||||
},
|
||||
{
|
||||
path: '/signin',
|
||||
element: <SignInPage />,
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
element: <SignUpPage />,
|
||||
},
|
||||
{
|
||||
path: '/user/:username',
|
||||
element: <UserLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <UserPage />,
|
||||
},
|
||||
{
|
||||
path: '/user/:username/about',
|
||||
element: <UserAboutPage />,
|
||||
},
|
||||
{
|
||||
path: '/user/:username/settings',
|
||||
element: <UserSettingsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/panels',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <ExplorePanelsPage />,
|
||||
},
|
||||
{
|
||||
path: '/panels/new',
|
||||
element: <NewPanelPage />,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/panel/:panelName',
|
||||
element: <PanelLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <PanelPage />,
|
||||
},
|
||||
{
|
||||
path: '/panel/:panelName/settings',
|
||||
element: <PanelSettingsPage />,
|
||||
},
|
||||
{
|
||||
path: '/panel/:panelName/post/:postId',
|
||||
element: <PanelPostPage />,
|
||||
},
|
||||
{
|
||||
path: '/panel/:panelName/posts/new',
|
||||
element: <NewPanelPostPage />,
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS>
|
||||
<RouterProvider router={router} fallbackElement={<LoadingBar />} />
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
24
services/frontend/src/app/api/auth.ts
Normal file
24
services/frontend/src/app/api/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { apiSlice } from '../features/api'
|
||||
import { convertRawAuthData } from '../types/auth'
|
||||
|
||||
import type { AuthData } from '../types/common'
|
||||
import type { LoginRequest, RawAuthResponse } from '../types/auth'
|
||||
|
||||
export const authApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation<AuthData, LoginRequest>({
|
||||
query: data => ({
|
||||
url: '/v1/auth/login',
|
||||
method: 'POST',
|
||||
body: { ...data }
|
||||
}),
|
||||
transformResponse: (response: RawAuthResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid auth response') }
|
||||
|
||||
return convertRawAuthData(response.data)
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const { useLoginMutation } = authApiSlice
|
||||
68
services/frontend/src/app/api/comments.ts
Normal file
68
services/frontend/src/app/api/comments.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { apiSlice } from '../features/api'
|
||||
import { convertRawComment } from '../types/comments'
|
||||
|
||||
import type { Comment } from '../types/common'
|
||||
import type {
|
||||
RawComment, RawCommentResponse, RawCommentsResponse,
|
||||
GetPostCommentsRequest,
|
||||
UpdatePostCommentRequest,
|
||||
DeletePostCommentRequest,
|
||||
CreatePostCommentRequest
|
||||
} from '../types/comments'
|
||||
|
||||
export const commentsApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getPostComments: builder.query<Comment[], GetPostCommentsRequest>({
|
||||
query: data => ({ url: `/v1/posts/${data.postId}/comments` }),
|
||||
transformResponse: (response: RawCommentsResponse) => {
|
||||
if (response.data === undefined) {
|
||||
throw Error('invalid comments response')
|
||||
} else if (!response.data.comments) {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.data.comments.map<Comment>((rawComment: RawComment) => convertRawComment(rawComment))
|
||||
}
|
||||
}),
|
||||
|
||||
updatePostComment: builder.mutation<Comment, UpdatePostCommentRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/posts/${req.postId}/comments/${req.id}`,
|
||||
method: 'PATCH',
|
||||
body: { ...req.data }
|
||||
}),
|
||||
transformResponse: (response: RawCommentResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid comment response') }
|
||||
|
||||
return convertRawComment(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
deletePostComment: builder.mutation<void, DeletePostCommentRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/posts/${req.postId}/comments/${req.id}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}),
|
||||
|
||||
createPostComment: builder.mutation<Comment, CreatePostCommentRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/posts/${req.postId}/comments`,
|
||||
method: 'POST',
|
||||
body: { ...req.data }
|
||||
}),
|
||||
transformResponse: (response: RawCommentResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid comment response') }
|
||||
|
||||
return convertRawComment(response.data)
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetPostCommentsQuery,
|
||||
useUpdatePostCommentMutation,
|
||||
useDeletePostCommentMutation,
|
||||
useCreatePostCommentMutation
|
||||
} = commentsApiSlice
|
||||
93
services/frontend/src/app/api/panels.ts
Normal file
93
services/frontend/src/app/api/panels.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { apiSlice } from '../features/api'
|
||||
import { convertRawPanel } from '../types/panels'
|
||||
|
||||
import type { Panel } from '../types/common'
|
||||
import type {
|
||||
RawPanelResponse,
|
||||
GetPanelByIdRequest, GetPanelByNameRequest,
|
||||
UpdatePanelByIdRequest, UpdatePanelByNameRequest,
|
||||
DeletePanelByIdRequest, DeletePanelByNameRequest,
|
||||
CreatePanelRequest
|
||||
} from '../types/panels'
|
||||
|
||||
export const panelsApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getPanelById: builder.query<Panel, GetPanelByIdRequest>({
|
||||
query: req => ({ url: `/v1/panels/id/${req.id}` }),
|
||||
transformResponse: (response: RawPanelResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid panel response') }
|
||||
|
||||
return convertRawPanel(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
getPanelByName: builder.query<Panel, GetPanelByNameRequest>({
|
||||
query: req => ({ url: `/v1/panels/name/${req.name}` }),
|
||||
transformResponse: (response: RawPanelResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid panel response') }
|
||||
|
||||
return convertRawPanel(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
updatePanelById: builder.mutation<Panel, UpdatePanelByIdRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/panels/id/${req.id}`,
|
||||
method: 'PATCH',
|
||||
body: { ...req.data }
|
||||
}),
|
||||
transformResponse: (response: RawPanelResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid panel response') }
|
||||
|
||||
return convertRawPanel(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
updatePanelByName: builder.mutation<Panel, UpdatePanelByNameRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/panels/name/${req.name}`,
|
||||
method: 'PATCH',
|
||||
body: { ...req.data }
|
||||
}),
|
||||
transformResponse: (response: RawPanelResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid panel response') }
|
||||
|
||||
return convertRawPanel(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
deletePanelById: builder.mutation<void, DeletePanelByIdRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/panels/id/${req.id}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}),
|
||||
|
||||
deletePanelByName: builder.mutation<void, DeletePanelByNameRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/panels/id/${req.name}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}),
|
||||
|
||||
createPanel: builder.mutation<Panel, CreatePanelRequest>({
|
||||
query: req => ({
|
||||
url: '/v1/panels',
|
||||
method: 'POST',
|
||||
body: { ...req }
|
||||
}),
|
||||
transformResponse: (response: RawPanelResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid panel response') }
|
||||
|
||||
return convertRawPanel(response.data)
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetPanelByIdQuery, useGetPanelByNameQuery,
|
||||
useUpdatePanelByIdMutation, useUpdatePanelByNameMutation,
|
||||
useDeletePanelByIdMutation, useDeletePanelByNameMutation,
|
||||
useCreatePanelMutation
|
||||
} = panelsApiSlice
|
||||
107
services/frontend/src/app/api/posts.ts
Normal file
107
services/frontend/src/app/api/posts.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { apiSlice } from '../features/api'
|
||||
import { convertRawPost } from '../types/posts'
|
||||
|
||||
import type { Post } from '../types/common'
|
||||
import type {
|
||||
RawPost, RawPostResponse, RawPostsResponse,
|
||||
GetPanelPostRequest, GetPanelPostsRequest,
|
||||
GetUserPostsRequest,
|
||||
UpdatePostRequest,
|
||||
DeletePostRequest,
|
||||
CreatePostRequest
|
||||
} from '../types/posts'
|
||||
|
||||
export const postsApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getPanelPost: builder.query<Post, GetPanelPostRequest>({
|
||||
query: req => ({ url: `/v1/panels/id/${req.panelId}/posts/${req.id}` }),
|
||||
transformResponse: (response: RawPostResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid post response') }
|
||||
|
||||
return convertRawPost(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
getFeedPosts: builder.query<Post[], void>({
|
||||
query: () => '/v1/posts/feed',
|
||||
transformResponse: (response: RawPostsResponse) => {
|
||||
if (response.data === undefined) {
|
||||
throw Error('invalid posts response')
|
||||
} else if (!response.data.posts) {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.data.posts.map<Post>((rawPost: RawPost) => convertRawPost(rawPost))
|
||||
}
|
||||
}),
|
||||
|
||||
getUserPosts: builder.query<Post[], GetUserPostsRequest>({
|
||||
query: req => `/v1/users/id/${req.userId}/posts`,
|
||||
transformResponse: (response: RawPostsResponse) => {
|
||||
if (response.data === undefined) {
|
||||
throw Error('invalid posts response')
|
||||
} else if (!response.data.posts) {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.data.posts.map<Post>((rawPost: RawPost) => convertRawPost(rawPost))
|
||||
}
|
||||
}),
|
||||
|
||||
getPanelPosts: builder.query<Post[], GetPanelPostsRequest>({
|
||||
query: req => `/v1/panels/id/${req.panelId}/posts`,
|
||||
transformResponse: (response: RawPostsResponse) => {
|
||||
if (response.data === undefined) {
|
||||
throw Error('invalid posts response')
|
||||
} else if (!response.data.posts) {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.data.posts.map<Post>((rawPost: RawPost) => convertRawPost(rawPost))
|
||||
}
|
||||
}),
|
||||
|
||||
updatePost: builder.mutation<Post, UpdatePostRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/posts/${req.id}`,
|
||||
method: 'PATCH',
|
||||
body: { ...req.data },
|
||||
}),
|
||||
transformResponse: (response: RawPostResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid post response') }
|
||||
|
||||
return convertRawPost(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
deletePost: builder.mutation<void, DeletePostRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/posts/${req.id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}),
|
||||
|
||||
createPanelPost: builder.mutation<Post, CreatePostRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/panels/id/${req.panelId}`,
|
||||
method: 'POST',
|
||||
body: { ...req.data },
|
||||
}),
|
||||
transformResponse: (response: RawPostResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid post response') }
|
||||
|
||||
return convertRawPost(response.data)
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetPanelPostQuery,
|
||||
useGetFeedPostsQuery,
|
||||
useGetUserPostsQuery,
|
||||
useGetPanelPostsQuery,
|
||||
useUpdatePostMutation,
|
||||
useDeletePostMutation,
|
||||
useCreatePanelPostMutation
|
||||
} = postsApiSlice
|
||||
83
services/frontend/src/app/api/users.ts
Normal file
83
services/frontend/src/app/api/users.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { apiSlice } from '../features/api'
|
||||
import { convertRawUser } from '../types/user'
|
||||
import { convertRawAuthData } from '../types/auth'
|
||||
|
||||
import type { User, AuthData } from '../types/common'
|
||||
import type { RawAuthResponse } from '../types/auth'
|
||||
import type {
|
||||
RawUserResponse,
|
||||
GetUserByIdRequest, GetUserByNameRequest,
|
||||
DeleteUserByIdRequest, DeleteUserByNameRequest,
|
||||
RegisterUserRequest
|
||||
} from '../types/user'
|
||||
|
||||
export const usersApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getUserById: builder.query<User, GetUserByIdRequest>({
|
||||
query: req => ({ url: `/v1/users/id/${req.id}` }),
|
||||
transformResponse: (response: RawUserResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid user response') }
|
||||
|
||||
return convertRawUser(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
getUserByName: builder.query<User, GetUserByNameRequest>({
|
||||
query: req => ({ url: `/v1/users/username/${req.username}` }),
|
||||
transformResponse: (response: RawUserResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid user response') }
|
||||
|
||||
return convertRawUser(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
getCurrentUser: builder.query<User, void>({
|
||||
query: () => ({ url: '/v1/users/me' }),
|
||||
transformResponse: (response: RawUserResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid user response') }
|
||||
|
||||
return convertRawUser(response.data)
|
||||
}
|
||||
}),
|
||||
|
||||
deleteUserById: builder.mutation<void, DeleteUserByIdRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/users/id/${req.id}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}),
|
||||
|
||||
deleteUserByName: builder.mutation<void, DeleteUserByNameRequest>({
|
||||
query: req => ({
|
||||
url: `/v1/users/username/${req.username}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}),
|
||||
|
||||
deleteCurrentUser: builder.mutation<void, void>({
|
||||
query: () => ({
|
||||
url: '/v1/users/me',
|
||||
method: 'DELETE'
|
||||
})
|
||||
}),
|
||||
|
||||
registerUser: builder.mutation<AuthData, RegisterUserRequest>({
|
||||
query: req => ({
|
||||
url: '/v1/users',
|
||||
method: 'POST',
|
||||
body: { ...req }
|
||||
}),
|
||||
transformResponse: (response: RawAuthResponse) => {
|
||||
if (response.data === undefined) { throw Error('invalid registration response') }
|
||||
|
||||
return convertRawAuthData(response.data)
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetUserByIdQuery, useGetUserByNameQuery, useGetCurrentUserQuery,
|
||||
useDeleteUserByIdMutation, useDeleteUserByNameMutation, useDeleteCurrentUserMutation,
|
||||
useRegisterUserMutation
|
||||
} = usersApiSlice
|
||||
33
services/frontend/src/app/features/api.ts
Normal file
33
services/frontend/src/app/features/api.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import type { BaseQueryFn } from '@reduxjs/toolkit/query'
|
||||
|
||||
import { setUnauthed } from './auth'
|
||||
import type { RootState } from '../store'
|
||||
|
||||
const baseQuery = fetchBaseQuery({
|
||||
baseUrl: import.meta.env.VITE_API_URL,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
const state = getState() as RootState
|
||||
|
||||
const token = state.auth.accessToken
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
})
|
||||
|
||||
const wrappedBaseQuery: BaseQueryFn = async (args, api, extraOptions) => {
|
||||
const result = await baseQuery(args, api, extraOptions)
|
||||
if ((api.getState() as RootState).auth.accessToken && result?.error?.status === 403) {
|
||||
api.dispatch(setUnauthed())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const apiSlice = createApi({
|
||||
baseQuery: wrappedBaseQuery,
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
44
services/frontend/src/app/features/auth.ts
Normal file
44
services/frontend/src/app/features/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
import { authApiSlice } from '../api/auth'
|
||||
import { usersApiSlice } from '../api/users'
|
||||
import type { User } from '../types/common'
|
||||
|
||||
export interface AuthState {
|
||||
accessToken: string | null;
|
||||
currentUser: User | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
accessToken: null,
|
||||
currentUser: null
|
||||
}
|
||||
|
||||
export const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUnauthed: state => {
|
||||
state.accessToken = null
|
||||
state.currentUser = null
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addMatcher(
|
||||
authApiSlice.endpoints.login.matchFulfilled,
|
||||
(state, { payload }) => {
|
||||
state.accessToken = payload.token.access_token
|
||||
state.currentUser = payload.user
|
||||
}
|
||||
).addMatcher(
|
||||
usersApiSlice.endpoints.registerUser.matchFulfilled,
|
||||
(state, { payload }) => {
|
||||
state.accessToken = payload.token.access_token
|
||||
state.currentUser = payload.user
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const { setUnauthed } = authSlice.actions
|
||||
export default authSlice.reducer
|
||||
7
services/frontend/src/app/hooks.ts
Normal file
7
services/frontend/src/app/hooks.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import type { TypedUseSelectorHook } from 'react-redux'
|
||||
|
||||
import type { RootState, AppDispatch } from './store'
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||
16
services/frontend/src/app/store.ts
Normal file
16
services/frontend/src/app/store.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
import { apiSlice } from './features/api'
|
||||
import authReducer from './features/auth'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
[apiSlice.reducerPath]: apiSlice.reducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware().concat(apiSlice.middleware)
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
19
services/frontend/src/app/types/api.ts
Normal file
19
services/frontend/src/app/types/api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type RawResponse = {
|
||||
status: string;
|
||||
msg?: string;
|
||||
data?: object;
|
||||
}
|
||||
|
||||
export type ErrorResponse = RawResponse & {
|
||||
msg: string;
|
||||
data?: null;
|
||||
}
|
||||
|
||||
export type RawTimestamp = {
|
||||
seconds: number;
|
||||
nanos?: number;
|
||||
}
|
||||
|
||||
export const convertRawTimestamp = (timestamp: RawTimestamp): string => {
|
||||
return new Date(timestamp.seconds * 1000).toISOString()
|
||||
}
|
||||
27
services/frontend/src/app/types/auth.ts
Normal file
27
services/frontend/src/app/types/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { convertRawUser } from './user'
|
||||
|
||||
import type { RawUser } from './user'
|
||||
import type { RawResponse } from './api'
|
||||
import type { AuthData, AuthToken } from './common'
|
||||
|
||||
// API Request Paramaters
|
||||
export type LoginRequest = {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// API Responses
|
||||
type RawAuthData = {
|
||||
token: AuthToken,
|
||||
user: RawUser
|
||||
}
|
||||
|
||||
export type RawAuthResponse = RawResponse & {
|
||||
data?: RawAuthData;
|
||||
}
|
||||
|
||||
// API Response Conversion
|
||||
export const convertRawAuthData = (data: RawAuthData): AuthData => ({
|
||||
token: data.token,
|
||||
user: convertRawUser(data.user)
|
||||
})
|
||||
68
services/frontend/src/app/types/comments.ts
Normal file
68
services/frontend/src/app/types/comments.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { convertRawTimestamp } from './api';
|
||||
|
||||
import type { Comment } from './common';
|
||||
import type { RawResponse, RawTimestamp } from './api';
|
||||
|
||||
// Request Data
|
||||
export type CreateCommentData = {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type UpdateCommentData = Partial<CreateCommentData>
|
||||
|
||||
// API Request Paramaters
|
||||
export type GetPostCommentsRequest = {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
type UpdateCommentRequest = {
|
||||
id: string;
|
||||
data: UpdateCommentData;
|
||||
}
|
||||
|
||||
export type UpdatePostCommentRequest = UpdateCommentRequest & {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
type DeleteCommentRequest = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type DeletePostCommentRequest = DeleteCommentRequest & {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
export type CreatePostCommentRequest = {
|
||||
postId: string;
|
||||
data: CreateCommentData;
|
||||
}
|
||||
|
||||
// API Responses
|
||||
export type RawComment = {
|
||||
id: string;
|
||||
post_id: string;
|
||||
author_id: string;
|
||||
message: string;
|
||||
created_at: RawTimestamp;
|
||||
updated_at?: RawTimestamp;
|
||||
}
|
||||
|
||||
export type RawCommentResponse = RawResponse & {
|
||||
data?: RawComment;
|
||||
}
|
||||
|
||||
export type RawCommentsResponse = RawResponse & {
|
||||
data?: {
|
||||
comments: RawComment[];
|
||||
};
|
||||
}
|
||||
|
||||
// API Response Conversion
|
||||
export const convertRawComment = (rawComment: RawComment): Comment => ({
|
||||
id: rawComment.id,
|
||||
postId: rawComment.post_id,
|
||||
authorId: rawComment.author_id,
|
||||
message: rawComment.message,
|
||||
createdAt: convertRawTimestamp(rawComment.created_at),
|
||||
updatedAt: (rawComment.updated_at ? convertRawTimestamp(rawComment.updated_at) : undefined),
|
||||
})
|
||||
50
services/frontend/src/app/types/common.ts
Normal file
50
services/frontend/src/app/types/common.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Auth
|
||||
export type AuthData = {
|
||||
token: AuthToken;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export type AuthToken = {
|
||||
token_type: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
// Panel
|
||||
export type Panel = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Post
|
||||
export type Post = {
|
||||
id: string;
|
||||
panelId: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Comment
|
||||
export type Comment = {
|
||||
id: string;
|
||||
postId: string;
|
||||
authorId: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// User
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
59
services/frontend/src/app/types/panels.ts
Normal file
59
services/frontend/src/app/types/panels.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { convertRawTimestamp } from './api';
|
||||
|
||||
import type { Panel } from './common';
|
||||
import type { RawResponse, RawTimestamp } from './api';
|
||||
|
||||
// Request Data
|
||||
export type CreatePanelData = {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type UpdatePanelData = Partial<CreatePanelData>
|
||||
|
||||
// API Request Paramaters
|
||||
type PanelByIdBase = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type PanelByNameBase = {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type GetPanelByIdRequest = PanelByIdBase;
|
||||
export type GetPanelByNameRequest = PanelByNameBase;
|
||||
|
||||
export type UpdatePanelByIdRequest = PanelByIdBase & {
|
||||
data: UpdatePanelData;
|
||||
}
|
||||
|
||||
export type UpdatePanelByNameRequest = PanelByNameBase & {
|
||||
data: UpdatePanelData;
|
||||
}
|
||||
|
||||
export type DeletePanelByIdRequest = PanelByIdBase;
|
||||
export type DeletePanelByNameRequest = PanelByNameBase;
|
||||
|
||||
export type CreatePanelRequest = CreatePanelData;
|
||||
|
||||
// API Responses
|
||||
export type RawPanel = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: RawTimestamp;
|
||||
updated_at?: RawTimestamp;
|
||||
}
|
||||
|
||||
export type RawPanelResponse = RawResponse & {
|
||||
data?: RawPanel;
|
||||
}
|
||||
|
||||
// API Response Conversion
|
||||
export const convertRawPanel = (rawPanel: RawPanel): Panel => ({
|
||||
id: rawPanel.id,
|
||||
name: rawPanel.name,
|
||||
description: rawPanel.description,
|
||||
createdAt: convertRawTimestamp(rawPanel.created_at),
|
||||
updatedAt: (rawPanel.updated_at ? convertRawTimestamp(rawPanel.updated_at) : undefined),
|
||||
})
|
||||
72
services/frontend/src/app/types/posts.ts
Normal file
72
services/frontend/src/app/types/posts.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { convertRawTimestamp } from './api'
|
||||
|
||||
import type { Post } from './common'
|
||||
import type { RawResponse, RawTimestamp } from './api'
|
||||
|
||||
// Request Data
|
||||
export type CreatePostData = {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type UpdatePostData = Partial<CreatePostData>
|
||||
|
||||
// API Request Paramaters
|
||||
export type GetPanelPostRequest = {
|
||||
id: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
export type GetUserPostsRequest = {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type GetPanelPostsRequest = {
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
export type UpdatePostRequest = {
|
||||
id: string;
|
||||
data: UpdatePostData;
|
||||
}
|
||||
|
||||
export type DeletePostRequest = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type CreatePostRequest = {
|
||||
panelId: string;
|
||||
data: CreatePostData;
|
||||
}
|
||||
|
||||
// API Responses
|
||||
export type RawPost = {
|
||||
id: string;
|
||||
panel_id: string;
|
||||
author_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
created_at: RawTimestamp;
|
||||
updated_at?: RawTimestamp;
|
||||
}
|
||||
|
||||
export type RawPostResponse = RawResponse & {
|
||||
data?: RawPost;
|
||||
}
|
||||
|
||||
export type RawPostsResponse = RawResponse & {
|
||||
data?: {
|
||||
posts: RawPost[];
|
||||
};
|
||||
}
|
||||
|
||||
// API Response Conversion
|
||||
export const convertRawPost = (rawPost: RawPost): Post => ({
|
||||
id: rawPost.id,
|
||||
panelId: rawPost.panel_id,
|
||||
authorId: rawPost.author_id,
|
||||
title: rawPost.title,
|
||||
content: rawPost.content,
|
||||
createdAt: convertRawTimestamp(rawPost.created_at),
|
||||
updatedAt: (rawPost.updated_at ? convertRawTimestamp(rawPost.updated_at) : undefined),
|
||||
})
|
||||
49
services/frontend/src/app/types/user.ts
Normal file
49
services/frontend/src/app/types/user.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { convertRawTimestamp } from './api';
|
||||
|
||||
import type { User } from './common';
|
||||
import type { RawResponse, RawTimestamp } from './api';
|
||||
|
||||
// Request Data
|
||||
type RegisterUserData = {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// API Request Paramaters
|
||||
type UserByIdBase = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type UserByNameBase = {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export type GetUserByIdRequest = UserByIdBase
|
||||
export type GetUserByNameRequest = UserByNameBase
|
||||
|
||||
export type DeleteUserByIdRequest = UserByIdBase
|
||||
export type DeleteUserByNameRequest = UserByNameBase
|
||||
|
||||
export type RegisterUserRequest = RegisterUserData
|
||||
|
||||
// API Responses
|
||||
export type RawUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin?: boolean;
|
||||
created_at?: RawTimestamp;
|
||||
updated_at?: RawTimestamp;
|
||||
}
|
||||
|
||||
export type RawUserResponse = RawResponse & {
|
||||
data?: RawUser;
|
||||
}
|
||||
|
||||
// API Response Conversion
|
||||
export const convertRawUser = (rawUser: RawUser): User => ({
|
||||
id: rawUser.id,
|
||||
username: rawUser.username,
|
||||
isAdmin: (rawUser.is_admin ? rawUser.is_admin : false),
|
||||
createdAt: (rawUser.created_at ? convertRawTimestamp(rawUser.created_at) : undefined),
|
||||
updatedAt: (rawUser.updated_at ? convertRawTimestamp(rawUser.updated_at) : undefined),
|
||||
})
|
||||
1
services/frontend/src/assets/logo.svg
Normal file
1
services/frontend/src/assets/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.9 KiB |
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
|
||||
12
services/frontend/src/main.tsx
Normal file
12
services/frontend/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import { Provider } from 'react-redux'
|
||||
|
||||
import App from './App.tsx'
|
||||
import { store } from './app/store'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
)
|
||||
45
services/frontend/src/pages/Error.tsx
Normal file
45
services/frontend/src/pages/Error.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Link, useRouteError, isRouteErrorResponse } from 'react-router-dom'
|
||||
import { Title, Text, Button, Center, Container, Group, rem } from '@mantine/core'
|
||||
|
||||
const ErrorPage = () => {
|
||||
const error = useRouteError()
|
||||
|
||||
let title = 'Uh, oh!'
|
||||
let subTitle = 'Something went wrong.'
|
||||
if (isRouteErrorResponse(error)) {
|
||||
title = `Error ${error.status}`
|
||||
subTitle = error.statusText
|
||||
} else if (error instanceof Error) {
|
||||
subTitle = error.message
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h='100%'>
|
||||
<Container>
|
||||
<Title
|
||||
align='center'
|
||||
weight={800}
|
||||
sx={(theme) => ({
|
||||
fontSize: rem(38),
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
fontSize: rem(32),
|
||||
},
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
<Text size='lg' color='dimmed' maw={rem(250)} align='center' my='xl'>
|
||||
{subTitle}
|
||||
</Text>
|
||||
|
||||
<Group position='center'>
|
||||
<Button component={Link} to='/' variant='subtle' size='md'>
|
||||
Back to Home
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorPage
|
||||
47
services/frontend/src/pages/ExplorePanels.tsx
Normal file
47
services/frontend/src/pages/ExplorePanels.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { IconMessages, IconTableOff } from '@tabler/icons-react'
|
||||
import { Container, Stack, Paper, Box, Title, Text, Anchor, Divider, ThemeIcon, Group } from '@mantine/core'
|
||||
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
|
||||
const ExplorePanelsPage = () => {
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
|
||||
return (
|
||||
<Container mt='xl'>
|
||||
<Title>Explore Panels</Title>
|
||||
{currentUser && (
|
||||
<Text color='dimmed' size='sm' mt={5}>
|
||||
Alternatively you could <Anchor size='sm' component={Link} to='/panels/new'>create your own.</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
<Divider my='md' variant='dotted' />
|
||||
|
||||
<Stack spacing='sm' align='stretch'>
|
||||
<Paper shadow='xl' radius='md' p='md' withBorder component={Link} to='/panel/Panel'>
|
||||
<Group>
|
||||
<ThemeIcon color='teal' variant='light' size='xl'><IconMessages /></ThemeIcon>
|
||||
<Box>
|
||||
<Text weight={600}>Panel</Text>
|
||||
<Text>The first and therefore defacto primary panel.</Text>
|
||||
<Text color='dimmed' size='xs' mt={3}>Click to View</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper shadow='xl' radius='md' p='md' withBorder component='a' href='https://github.com/hexolan/Panels/'>
|
||||
<Group>
|
||||
<ThemeIcon color='red' variant='light' size='xl'><IconTableOff /></ThemeIcon>
|
||||
<Box>
|
||||
<Text weight={600}>Note</Text>
|
||||
<Text>This page is exemplary as this feature is currently unimplemented.</Text>
|
||||
<Text color='dimmed' size='xs' mt={3}>Planned Functionality</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExplorePanelsPage
|
||||
17
services/frontend/src/pages/Home.tsx
Normal file
17
services/frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Container, Title, Stack, Divider } from '@mantine/core'
|
||||
|
||||
import HomePostFeed from '../components/HomePostFeed'
|
||||
|
||||
const Homepage = () => {
|
||||
return (
|
||||
<Container mt='xl'>
|
||||
<Title>Feed</Title>
|
||||
<Divider my='md' variant='dotted' />
|
||||
<Stack my='lg' spacing='md'>
|
||||
<HomePostFeed />
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Homepage
|
||||
77
services/frontend/src/pages/NewPanel.tsx
Normal file
77
services/frontend/src/pages/NewPanel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm, hasLength } from '@mantine/form'
|
||||
import { Center, Container, Paper, Title, Text, TextInput, Textarea, Stack, Button } from '@mantine/core'
|
||||
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
import { useCreatePanelMutation } from '../app/api/panels'
|
||||
import type { CreatePanelData } from '../app/types/panels'
|
||||
|
||||
const NewPanelPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
// Ensure the user is authenticated
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
if (currentUser === null) {
|
||||
throw Error('Authentication Required')
|
||||
}
|
||||
|
||||
const panelForm = useForm<CreatePanelData>({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
validate: {
|
||||
name: hasLength({ min: 3, max: 20 }, 'Panel name must be between 3 and 20 characters long'),
|
||||
description: hasLength({ min: 3, max: 512 }, 'Description must be between 3 and 512 characters'),
|
||||
}
|
||||
})
|
||||
|
||||
const [createPanel, { isLoading }] = useCreatePanelMutation()
|
||||
const submitPanelForm = async (values: CreatePanelData) => {
|
||||
await createPanel({
|
||||
...values
|
||||
}).unwrap().then((panel) => {
|
||||
navigate(`/panel/${panel.name}`)
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h='95%'>
|
||||
<Container>
|
||||
<Title align='center' weight={900}>Create a Panel</Title>
|
||||
|
||||
<Paper withBorder shadow='md' radius='md' p={30} mt={30}>
|
||||
<form onSubmit={panelForm.onSubmit(submitPanelForm)}>
|
||||
<Stack spacing='md'>
|
||||
<TextInput
|
||||
label='Name'
|
||||
placeholder='e.g. music, programming, football'
|
||||
{...panelForm.getInputProps('name')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label='Description'
|
||||
placeholder='e.g. The place to talk about all things music related...'
|
||||
{...panelForm.getInputProps('description')}
|
||||
/>
|
||||
|
||||
{ errorMsg && <Text color='red' align='center'>{'Error: ' + errorMsg}</Text> }
|
||||
|
||||
<Button type='submit' variant='outline' color='teal' disabled={isLoading} fullWidth>Create Panel</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPanelPage
|
||||
76
services/frontend/src/pages/NewPanelPost.tsx
Normal file
76
services/frontend/src/pages/NewPanelPost.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom'
|
||||
import { useForm, hasLength } from '@mantine/form'
|
||||
import { Stack, Paper, Text, TextInput, Textarea, Button } from '@mantine/core'
|
||||
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
import { useCreatePanelPostMutation } from '../app/api/posts'
|
||||
import type { CreatePostData } from '../app/types/posts'
|
||||
import type { PanelContext } from '../components/PanelLayout'
|
||||
|
||||
const NewPanelPostPage = () => {
|
||||
const { panel } = useOutletContext<PanelContext>()
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Ensure the user is authenticated
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
if (currentUser === null) {
|
||||
throw Error('You must be authenticated to create posts')
|
||||
}
|
||||
|
||||
const createPostForm = useForm<CreatePostData>({
|
||||
initialValues: {
|
||||
title: '',
|
||||
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 [createPost, { isLoading }] = useCreatePanelPostMutation()
|
||||
const submitPost = async (values: CreatePostData) => {
|
||||
await createPost({
|
||||
panelId: panel.id,
|
||||
data: values
|
||||
}).unwrap().then((post) => {
|
||||
navigate(`/panel/${panel.name}/post/${post.id}`)
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper shadow='md' radius='md' p='lg' withBorder>
|
||||
<form onSubmit={createPostForm.onSubmit(submitPost)}>
|
||||
<Stack spacing='md'>
|
||||
<TextInput
|
||||
label='Title'
|
||||
placeholder='Post Title'
|
||||
{...createPostForm.getInputProps('title')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label='Content'
|
||||
placeholder='Post Content'
|
||||
{...createPostForm.getInputProps('content')}
|
||||
/>
|
||||
|
||||
{ errorMsg && <Text color='red' align='center'>{'Error: ' + errorMsg}</Text> }
|
||||
|
||||
<Button type='submit' variant='outline' color='teal' disabled={isLoading} fullWidth>
|
||||
Create Post
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPanelPostPage
|
||||
17
services/frontend/src/pages/Panel.tsx
Normal file
17
services/frontend/src/pages/Panel.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { Stack } from '@mantine/core'
|
||||
|
||||
import PanelPostFeed from '../components/PanelPostFeed'
|
||||
import type { PanelContext } from '../components/PanelLayout'
|
||||
|
||||
function PanelPage() {
|
||||
const { panel } = useOutletContext<PanelContext>()
|
||||
|
||||
return (
|
||||
<Stack my='lg' spacing='md'>
|
||||
<PanelPostFeed panel={panel} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelPage
|
||||
65
services/frontend/src/pages/PanelPost.tsx
Normal file
65
services/frontend/src/pages/PanelPost.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useOutletContext } from 'react-router-dom'
|
||||
import { Box, Stack, Divider } from '@mantine/core'
|
||||
|
||||
import PagePost from '../components/PagePost'
|
||||
import CommentsFeed from '../components/CommentsFeed'
|
||||
import PostCommentsFeed from '../components/PostCommentsFeed'
|
||||
import CreateComment from '../components/CreateComment'
|
||||
import LoadingBar from '../components/LoadingBar'
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
import { useGetPanelPostQuery } from '../app/api/posts'
|
||||
import type { PanelContext } from '../components/PanelLayout'
|
||||
import type { ErrorResponse } from '../app/types/api'
|
||||
import type { Comment } from '../app/types/common'
|
||||
|
||||
type PanelPostPageParams = {
|
||||
panelName: string;
|
||||
postId: string;
|
||||
}
|
||||
|
||||
function PanelPostPage() {
|
||||
const { panel } = useOutletContext<PanelContext>()
|
||||
const { postId } = useParams<PanelPostPageParams>();
|
||||
if (postId === undefined) { throw Error('post id not provided') }
|
||||
|
||||
const [newComments, setNewComments] = useState<Comment[]>([])
|
||||
const addNewComment = (comment: Comment) => {
|
||||
setNewComments([comment].concat(newComments))
|
||||
}
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
|
||||
// Fetch the post
|
||||
const { data, error, isLoading } = useGetPanelPostQuery({ panelId: panel.id, id: postId })
|
||||
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 (
|
||||
<Box mb='lg'>
|
||||
<PagePost post={data} />
|
||||
<Divider my='md' variant='none' />
|
||||
<Stack spacing='sm'>
|
||||
{ currentUser && <CreateComment post={data} addNewComment={addNewComment} /> }
|
||||
{ newComments.length > 0 && <CommentsFeed comments={newComments} /> }
|
||||
<PostCommentsFeed post={data} />
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelPostPage
|
||||
109
services/frontend/src/pages/PanelSettings.tsx
Normal file
109
services/frontend/src/pages/PanelSettings.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { useForm, hasLength } from '@mantine/form'
|
||||
import { Paper, Center, Stack, Group, Text, TextInput, Textarea, Button } from '@mantine/core'
|
||||
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
import { useDeletePanelByIdMutation, useUpdatePanelByIdMutation } from '../app/api/panels'
|
||||
import type { Panel } from '../app/types/common'
|
||||
import type { UpdatePanelData } from '../app/types/panels'
|
||||
import type { PanelContext } from '../components/PanelLayout'
|
||||
|
||||
const UpdatePanelForm = ({
|
||||
panel,
|
||||
setPanel,
|
||||
setModifying,
|
||||
setErrorMsg
|
||||
}: {
|
||||
panel: Panel,
|
||||
setPanel: React.Dispatch<Panel>,
|
||||
setModifying: React.Dispatch<boolean>,
|
||||
setErrorMsg: React.Dispatch<string>
|
||||
}) => {
|
||||
const panelForm = useForm<UpdatePanelData>({
|
||||
initialValues: {
|
||||
name: panel.name,
|
||||
description: panel.description,
|
||||
},
|
||||
validate: {
|
||||
name: hasLength({ min: 3, max: 20 }, 'Name must be between 3 and 20 characters long'),
|
||||
description: hasLength({ min: 3, max: 512 }, 'Description must be between 3 and 512 characters'),
|
||||
}
|
||||
})
|
||||
|
||||
const [updatePanel, { isLoading }] = useUpdatePanelByIdMutation()
|
||||
const submitUpdatePanel = async (values: UpdatePanelData) => {
|
||||
await updatePanel({
|
||||
id: panel.id,
|
||||
data: values
|
||||
}).unwrap().then((panelInfo) => {
|
||||
setErrorMsg('')
|
||||
setModifying(false)
|
||||
setPanel(panelInfo)
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={panelForm.onSubmit(submitUpdatePanel)}>
|
||||
<Stack spacing='md'>
|
||||
<TextInput label='Name' {...panelForm.getInputProps('name')} />
|
||||
<Textarea label='Description' {...panelForm.getInputProps('description')} />
|
||||
|
||||
<Button type='submit' variant='outline' color='teal' disabled={isLoading} fullWidth>Update</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [errorMsg, setErrorMsg] = useState<string>('')
|
||||
const [modifying, setModifying] = useState<boolean>(false)
|
||||
const { panel, setPanel } = useOutletContext<PanelContext>()
|
||||
|
||||
// Check permissions
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
if (!currentUser || !currentUser.isAdmin) {
|
||||
throw new Error('You do not have permission to view that page.')
|
||||
}
|
||||
|
||||
// Panel Deletion
|
||||
const [deletePanel, { isLoading: isLoadingDelPanel }] = useDeletePanelByIdMutation()
|
||||
const submitDeletePanel = async () => {
|
||||
await deletePanel({ id: panel.id }).unwrap().then(() => {
|
||||
navigate('/')
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper mt='md' radius='lg' shadow='md' p='lg' withBorder>
|
||||
<Center>
|
||||
<Group spacing='sm'>
|
||||
{modifying
|
||||
? <Button color='teal' onClick={() => { setModifying(false); setErrorMsg('') }}>Stop Modifying</Button>
|
||||
: <Button color='teal' onClick={() => setModifying(true)}>Modify Panel</Button>
|
||||
}
|
||||
<Button color='red' onClick={() => submitDeletePanel()} disabled={isLoadingDelPanel}>Delete Panel</Button>
|
||||
</Group>
|
||||
</Center>
|
||||
|
||||
{modifying && <UpdatePanelForm panel={panel} setPanel={setPanel} setModifying={setModifying} setErrorMsg={setErrorMsg} />}
|
||||
|
||||
{errorMsg && <Text color='red' mt='sm'>{'Error: ' + errorMsg}</Text>}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelSettingsPage
|
||||
83
services/frontend/src/pages/SignIn.tsx
Normal file
83
services/frontend/src/pages/SignIn.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useForm, hasLength } from '@mantine/form'
|
||||
import { Center, Container, Paper, Title, Text, Anchor, TextInput, PasswordInput, Stack, Button } from '@mantine/core'
|
||||
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
import { useLoginMutation } from '../app/api/auth'
|
||||
import type { LoginRequest } from '../app/types/auth'
|
||||
|
||||
function SignInPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Ensure the user isn't authenticated already
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
if (currentUser) {
|
||||
throw new Error('You are already authenticated.')
|
||||
}
|
||||
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const loginForm = useForm<LoginRequest>({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
validate: {
|
||||
username: hasLength({ min: 3, max: 32 }, 'Invalid username'),
|
||||
password: hasLength({ min: 8 }, 'Invalid password'),
|
||||
}
|
||||
})
|
||||
|
||||
const [login, { isLoading }] = useLoginMutation()
|
||||
const submitLoginForm = async (values: LoginRequest) => {
|
||||
// Attempt to authenticate the user.
|
||||
await login(values).unwrap().then(() => {
|
||||
// Redirect to homepage.
|
||||
navigate('/')
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h='95%'>
|
||||
<Container>
|
||||
<Title align='center' weight={900}>Sign In</Title>
|
||||
<Text color='dimmed' size='sm' align='center' mt={5}>
|
||||
Do not have an account yet?{' '}
|
||||
<Anchor size='sm' component={Link} to='/signup'>Sign up</Anchor> instead.
|
||||
</Text>
|
||||
|
||||
|
||||
<Paper withBorder shadow='md' radius='md' p={30} mt={30}>
|
||||
<form onSubmit={loginForm.onSubmit(submitLoginForm)}>
|
||||
<Stack spacing='md'>
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Your username'
|
||||
{...loginForm.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Your password'
|
||||
{...loginForm.getInputProps('password')}
|
||||
/>
|
||||
|
||||
{ errorMsg && <Text color='red' align='center'>{'Error: ' + errorMsg}</Text> }
|
||||
|
||||
<Button type='submit' color='teal' disabled={isLoading} fullWidth>Login</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignInPage
|
||||
97
services/frontend/src/pages/SignUp.tsx
Normal file
97
services/frontend/src/pages/SignUp.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useForm, hasLength, matchesField } from '@mantine/form'
|
||||
import { Center, Container, Paper, Title, Text, Anchor, TextInput, PasswordInput, Stack, Button } from '@mantine/core'
|
||||
|
||||
import { useAppSelector } from '../app/hooks'
|
||||
import { useRegisterUserMutation } from '../app/api/users'
|
||||
|
||||
type RegistrationFormValues = {
|
||||
username: string;
|
||||
password: string;
|
||||
confPassword: string;
|
||||
}
|
||||
|
||||
const SignUpPage = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Ensure the user isn't authenticated already
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
if (currentUser) {
|
||||
throw new Error('You are already authenticated.')
|
||||
}
|
||||
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const registrationForm = useForm<RegistrationFormValues>({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
confPassword: '',
|
||||
},
|
||||
validate: {
|
||||
username: hasLength({ min: 3, max: 32 }, 'Username must be between 3 and 32 characters'),
|
||||
password: hasLength({ min: 8 }, 'Password must have a minimum of 8 characters'),
|
||||
confPassword: matchesField('password', 'Confirmation password does not match'),
|
||||
}
|
||||
})
|
||||
|
||||
const [registerUser, { isLoading }] = useRegisterUserMutation()
|
||||
const submitRegistrationForm = async (values: RegistrationFormValues) => {
|
||||
await registerUser({
|
||||
username: values.username,
|
||||
password: values.password
|
||||
}).unwrap().then(() => {
|
||||
// Redirect to homepage.
|
||||
navigate('/')
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h='95%'>
|
||||
<Container>
|
||||
<Title align='center' weight={900}>Sign Up</Title>
|
||||
<Text color='dimmed' size='sm' align='center' mt={5}>
|
||||
Already have an account?{' '}
|
||||
<Anchor size='sm' component={Link} to='/signin'>Sign in</Anchor> instead.
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow='md' radius='md' p={30} mt={30}>
|
||||
<form onSubmit={registrationForm.onSubmit(submitRegistrationForm)}>
|
||||
<Stack spacing='md'>
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Your username'
|
||||
{...registrationForm.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Your password'
|
||||
{...registrationForm.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Confirm Password'
|
||||
placeholder='Confirm password'
|
||||
{...registrationForm.getInputProps('confPassword')}
|
||||
/>
|
||||
|
||||
{ errorMsg && <Text color='red' align='center'>{'Error: ' + errorMsg}</Text> }
|
||||
|
||||
<Button type='submit' color='teal' disabled={isLoading} fullWidth>Register</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUpPage
|
||||
17
services/frontend/src/pages/User.tsx
Normal file
17
services/frontend/src/pages/User.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { Stack } from '@mantine/core'
|
||||
|
||||
import UserPostFeed from '../components/UserPostFeed'
|
||||
import type { UserContext } from '../components/UserLayout'
|
||||
|
||||
function UserPage() {
|
||||
const { user } = useOutletContext<UserContext>()
|
||||
|
||||
return (
|
||||
<Stack my='lg' spacing='md'>
|
||||
<UserPostFeed user={user} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserPage
|
||||
17
services/frontend/src/pages/UserAbout.tsx
Normal file
17
services/frontend/src/pages/UserAbout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { Paper, Text } from '@mantine/core'
|
||||
|
||||
import type { UserContext } from '../components/UserLayout'
|
||||
|
||||
function UserAboutPage() {
|
||||
const { user } = useOutletContext<UserContext>()
|
||||
|
||||
return (
|
||||
<Paper mt='md' radius='lg' shadow='md' p='lg' withBorder>
|
||||
<Text weight={500}>About {user.username}</Text>
|
||||
{user.createdAt && <Text>Signed up {new Date(user.createdAt).toUTCString()}</Text>}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserAboutPage
|
||||
43
services/frontend/src/pages/UserSettings.tsx
Normal file
43
services/frontend/src/pages/UserSettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { Paper, Text, Button } from '@mantine/core'
|
||||
|
||||
import { setUnauthed } from '../app/features/auth'
|
||||
import { useDeleteUserByIdMutation } from '../app/api/users'
|
||||
import { useAppSelector, useAppDispatch } from '../app/hooks'
|
||||
import type { UserContext } from '../components/UserLayout'
|
||||
|
||||
function UserSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const { user } = useOutletContext<UserContext>()
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||
if (user && (!currentUser || (currentUser.id != user.id && !currentUser.isAdmin))) {
|
||||
throw Error('You do not have permission to view that page')
|
||||
}
|
||||
|
||||
const [deleteUser, { isLoading }] = useDeleteUserByIdMutation()
|
||||
const submitDeleteAccount = async () => {
|
||||
await deleteUser({id: user.id}).unwrap().then(() => {
|
||||
dispatch(setUnauthed())
|
||||
navigate('/')
|
||||
}).catch((error) => {
|
||||
if (!error.data) {
|
||||
setErrorMsg('Failed to access the API')
|
||||
} else {
|
||||
setErrorMsg(error.data.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper mt='md' radius='lg' shadow='md' p='lg' withBorder>
|
||||
<Button color='red' onClick={() => submitDeleteAccount()} disabled={isLoading}>Delete Account</Button>
|
||||
{ errorMsg && <Text color='red'>{'Error: ' + errorMsg}</Text> }
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserSettingsPage
|
||||
9
services/frontend/src/vite-env.d.ts
vendored
Normal file
9
services/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
25
services/frontend/tsconfig.json
Normal file
25
services/frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
services/frontend/tsconfig.node.json
Normal file
10
services/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
6
services/frontend/vite.config.ts
Normal file
6
services/frontend/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
2183
services/frontend/yarn.lock
Normal file
2183
services/frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user