diff --git a/services/frontend/.dockerignore b/services/frontend/.dockerignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/services/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/services/frontend/.env.example b/services/frontend/.env.example new file mode 100644 index 0000000..cd41370 --- /dev/null +++ b/services/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3000 \ No newline at end of file diff --git a/services/frontend/.eslintrc.cjs b/services/frontend/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/services/frontend/.eslintrc.cjs @@ -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 }, + ], + }, +} diff --git a/services/frontend/Dockerfile b/services/frontend/Dockerfile new file mode 100644 index 0000000..e9efb4f --- /dev/null +++ b/services/frontend/Dockerfile @@ -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 \ No newline at end of file diff --git a/services/frontend/README.md b/services/frontend/README.md new file mode 100644 index 0000000..4a183fb --- /dev/null +++ b/services/frontend/README.md @@ -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 diff --git a/services/frontend/index.html b/services/frontend/index.html new file mode 100644 index 0000000..56b6262 --- /dev/null +++ b/services/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Panels + + +
+ + + diff --git a/services/frontend/nginx.conf b/services/frontend/nginx.conf new file mode 100644 index 0000000..db41ec8 --- /dev/null +++ b/services/frontend/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri /index.html =404; + } +} \ No newline at end of file diff --git a/services/frontend/package.json b/services/frontend/package.json new file mode 100644 index 0000000..facf601 --- /dev/null +++ b/services/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "frontend", + "description": "Panels - Frontend Site", + "version": "1.0.0", + "author": "Declan ", + "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" +} diff --git a/services/frontend/public/icon.svg b/services/frontend/public/icon.svg new file mode 100644 index 0000000..b5b645a --- /dev/null +++ b/services/frontend/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx new file mode 100644 index 0000000..e1e0dfc --- /dev/null +++ b/services/frontend/src/App.tsx @@ -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: , + errorElement: , + children: [ + { + index: true, + element: , + }, + { + path: '/signin', + element: , + }, + { + path: '/signup', + element: , + }, + { + path: '/user/:username', + element: , + children: [ + { + index: true, + element: , + }, + { + path: '/user/:username/about', + element: , + }, + { + path: '/user/:username/settings', + element: , + }, + ], + }, + { + path: '/panels', + children: [ + { + index: true, + element: , + }, + { + path: '/panels/new', + element: , + }, + ] + }, + { + path: '/panel/:panelName', + element: , + children: [ + { + index: true, + element: , + }, + { + path: '/panel/:panelName/settings', + element: , + }, + { + path: '/panel/:panelName/post/:postId', + element: , + }, + { + path: '/panel/:panelName/posts/new', + element: , + } + ], + }, + ] + } +]) + +function App() { + return ( + + } /> + + ); +} + +export default App \ No newline at end of file diff --git a/services/frontend/src/app/api/auth.ts b/services/frontend/src/app/api/auth.ts new file mode 100644 index 0000000..3e0eac6 --- /dev/null +++ b/services/frontend/src/app/api/auth.ts @@ -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({ + 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 \ No newline at end of file diff --git a/services/frontend/src/app/api/comments.ts b/services/frontend/src/app/api/comments.ts new file mode 100644 index 0000000..471fc9e --- /dev/null +++ b/services/frontend/src/app/api/comments.ts @@ -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({ + 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((rawComment: RawComment) => convertRawComment(rawComment)) + } + }), + + updatePostComment: builder.mutation({ + 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({ + query: req => ({ + url: `/v1/posts/${req.postId}/comments/${req.id}`, + method: 'DELETE' + }) + }), + + createPostComment: builder.mutation({ + 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 \ No newline at end of file diff --git a/services/frontend/src/app/api/panels.ts b/services/frontend/src/app/api/panels.ts new file mode 100644 index 0000000..9afa69a --- /dev/null +++ b/services/frontend/src/app/api/panels.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + query: req => ({ + url: `/v1/panels/id/${req.id}`, + method: 'DELETE' + }) + }), + + deletePanelByName: builder.mutation({ + query: req => ({ + url: `/v1/panels/id/${req.name}`, + method: 'DELETE' + }) + }), + + createPanel: builder.mutation({ + 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 \ No newline at end of file diff --git a/services/frontend/src/app/api/posts.ts b/services/frontend/src/app/api/posts.ts new file mode 100644 index 0000000..b4c3ad6 --- /dev/null +++ b/services/frontend/src/app/api/posts.ts @@ -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({ + 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({ + 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((rawPost: RawPost) => convertRawPost(rawPost)) + } + }), + + getUserPosts: builder.query({ + 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((rawPost: RawPost) => convertRawPost(rawPost)) + } + }), + + getPanelPosts: builder.query({ + 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((rawPost: RawPost) => convertRawPost(rawPost)) + } + }), + + updatePost: builder.mutation({ + 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({ + query: req => ({ + url: `/v1/posts/${req.id}`, + method: 'DELETE', + }) + }), + + createPanelPost: builder.mutation({ + 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 \ No newline at end of file diff --git a/services/frontend/src/app/api/users.ts b/services/frontend/src/app/api/users.ts new file mode 100644 index 0000000..d539cac --- /dev/null +++ b/services/frontend/src/app/api/users.ts @@ -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({ + 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({ + 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({ + query: () => ({ url: '/v1/users/me' }), + transformResponse: (response: RawUserResponse) => { + if (response.data === undefined) { throw Error('invalid user response') } + + return convertRawUser(response.data) + } + }), + + deleteUserById: builder.mutation({ + query: req => ({ + url: `/v1/users/id/${req.id}`, + method: 'DELETE' + }) + }), + + deleteUserByName: builder.mutation({ + query: req => ({ + url: `/v1/users/username/${req.username}`, + method: 'DELETE' + }) + }), + + deleteCurrentUser: builder.mutation({ + query: () => ({ + url: '/v1/users/me', + method: 'DELETE' + }) + }), + + registerUser: builder.mutation({ + 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 \ No newline at end of file diff --git a/services/frontend/src/app/features/api.ts b/services/frontend/src/app/features/api.ts new file mode 100644 index 0000000..99ad63b --- /dev/null +++ b/services/frontend/src/app/features/api.ts @@ -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: () => ({}), +}) \ No newline at end of file diff --git a/services/frontend/src/app/features/auth.ts b/services/frontend/src/app/features/auth.ts new file mode 100644 index 0000000..b3047aa --- /dev/null +++ b/services/frontend/src/app/features/auth.ts @@ -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 \ No newline at end of file diff --git a/services/frontend/src/app/hooks.ts b/services/frontend/src/app/hooks.ts new file mode 100644 index 0000000..489ca59 --- /dev/null +++ b/services/frontend/src/app/hooks.ts @@ -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 = useSelector \ No newline at end of file diff --git a/services/frontend/src/app/store.ts b/services/frontend/src/app/store.ts new file mode 100644 index 0000000..d41529f --- /dev/null +++ b/services/frontend/src/app/store.ts @@ -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 +export type AppDispatch = typeof store.dispatch \ No newline at end of file diff --git a/services/frontend/src/app/types/api.ts b/services/frontend/src/app/types/api.ts new file mode 100644 index 0000000..988e464 --- /dev/null +++ b/services/frontend/src/app/types/api.ts @@ -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() +} \ No newline at end of file diff --git a/services/frontend/src/app/types/auth.ts b/services/frontend/src/app/types/auth.ts new file mode 100644 index 0000000..daefd39 --- /dev/null +++ b/services/frontend/src/app/types/auth.ts @@ -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) +}) \ No newline at end of file diff --git a/services/frontend/src/app/types/comments.ts b/services/frontend/src/app/types/comments.ts new file mode 100644 index 0000000..2257181 --- /dev/null +++ b/services/frontend/src/app/types/comments.ts @@ -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 + +// 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), +}) \ No newline at end of file diff --git a/services/frontend/src/app/types/common.ts b/services/frontend/src/app/types/common.ts new file mode 100644 index 0000000..17af8ab --- /dev/null +++ b/services/frontend/src/app/types/common.ts @@ -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; +} \ No newline at end of file diff --git a/services/frontend/src/app/types/panels.ts b/services/frontend/src/app/types/panels.ts new file mode 100644 index 0000000..e13a02e --- /dev/null +++ b/services/frontend/src/app/types/panels.ts @@ -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 + +// 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), +}) \ No newline at end of file diff --git a/services/frontend/src/app/types/posts.ts b/services/frontend/src/app/types/posts.ts new file mode 100644 index 0000000..b7c5930 --- /dev/null +++ b/services/frontend/src/app/types/posts.ts @@ -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 + +// 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), +}) \ No newline at end of file diff --git a/services/frontend/src/app/types/user.ts b/services/frontend/src/app/types/user.ts new file mode 100644 index 0000000..18d4bb7 --- /dev/null +++ b/services/frontend/src/app/types/user.ts @@ -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), +}) \ No newline at end of file diff --git a/services/frontend/src/assets/logo.svg b/services/frontend/src/assets/logo.svg new file mode 100644 index 0000000..d044097 --- /dev/null +++ b/services/frontend/src/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/frontend/src/components/AppHeader.tsx b/services/frontend/src/components/AppHeader.tsx new file mode 100644 index 0000000..7def41f --- /dev/null +++ b/services/frontend/src/components/AppHeader.tsx @@ -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 ( +
+ + + Panels Logo + + {!currentUser ? ( + + ) : ( + + + + + + + User Actions + } component={Link} to={'/user/' + currentUser.username}>My Profile + } onClick={signoutUser}>Sign Out + + + )} + +
+ ) +} + +export default AppHeader \ No newline at end of file diff --git a/services/frontend/src/components/AppLayout.tsx b/services/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..d9ccf6a --- /dev/null +++ b/services/frontend/src/components/AppLayout.tsx @@ -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 ( + } + header={} + padding={0} + > + }> + {props?.children ? props.children : } + + + ); +} + +export default AppLayout \ No newline at end of file diff --git a/services/frontend/src/components/AppNavbar.tsx b/services/frontend/src/components/AppNavbar.tsx new file mode 100644 index 0000000..e4577f0 --- /dev/null +++ b/services/frontend/src/components/AppNavbar.tsx @@ -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 }) => ( + + {({ isActive }) => ( + ({ + display: 'block', + width: '100%', + borderRadius: theme.radius.sm, + + backgroundColor: isActive ? theme.colors.gray[0] : 'inherit', + '&:hover': { + backgroundColor: theme.colors.gray[0], + }, + })} + > + + + { createElement(icon, { size: '1rem' }) } + + + {text} + + + )} + +) + +function AppNavbar() { + return ( + + + Browse + + + + + ({ + borderTop: `${rem(1)} solid ${theme.colors.gray[3]}` + })} + > + Suggested Panels + + + + ) +} + +export default AppNavbar \ No newline at end of file diff --git a/services/frontend/src/components/CommentsFeed.tsx b/services/frontend/src/components/CommentsFeed.tsx new file mode 100644 index 0000000..fd1c33e --- /dev/null +++ b/services/frontend/src/components/CommentsFeed.tsx @@ -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 ( + + {Object.values(comments).map(comment => )} + + ) +} + +export default CommentsFeed \ No newline at end of file diff --git a/services/frontend/src/components/CreateComment.tsx b/services/frontend/src/components/CreateComment.tsx new file mode 100644 index 0000000..783afe8 --- /dev/null +++ b/services/frontend/src/components/CreateComment.tsx @@ -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({ + initialValues: { + message: '', + }, + validate: { + message: hasLength({ min: 3, max: 512 }, 'Message must be between 3 and 512 characters'), + } + }) + + return ( + +
+ +