init frontend

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

View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000

View 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 },
],
},
}

View 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

View 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

View 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>

View File

@@ -0,0 +1,9 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri /index.html =404;
}
}

View 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"
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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: () => ({}),
})

View 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

View 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

View 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

View 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()
}

View 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)
})

View 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),
})

View 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;
}

View 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),
})

View 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),
})

View 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),
})

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
)

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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

File diff suppressed because it is too large Load Diff