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