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