Almost finish implementing client side
This commit is contained in:
62
.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml
generated
62
.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml
generated
@@ -8,7 +8,19 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="4e14f3e6-3f4d-450c-8a9e-273b19823ca0" name="Changes" comment="" />
|
<list default="true" id="4e14f3e6-3f4d-450c-8a9e-273b19823ca0" name="Changes" comment="">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard.sln.DotSettings.user" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard.sln.DotSettings.user" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/PostController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/PostController.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/TagController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Controllers/TagController.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Comment/CommentDto.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Comment/CommentDto.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Post/PostDto.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Post/PostDto.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Tag/CreateTagDto.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Dtos/Tag/CreateTagDto.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Models/Tag.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Models/Tag.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Program.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Program.cs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/T120B165-ImgBoard/Services/PostService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/PostService.cs" afterDir="false" />
|
||||||
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
@@ -31,6 +43,7 @@
|
|||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/4afd6eaa21b44c15b819957c24b8b6071daa00/a4/86d4eee3/StatusCodeResult.cs" root0="FORCE_HIGHLIGHTING" />
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/4afd6eaa21b44c15b819957c24b8b6071daa00/a4/86d4eee3/StatusCodeResult.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/560c8584411b436bb783d358249acd4fe400/15/6f12b841/IHostEnvironment.cs" root0="SKIP_HIGHLIGHTING" />
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/560c8584411b436bb783d358249acd4fe400/15/6f12b841/IHostEnvironment.cs" root0="SKIP_HIGHLIGHTING" />
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/8cb87e61921c49bcadb380781eccdeb4228600/16/829c32b2/NSwagApplicationBuilderExtensions.cs" root0="SKIP_HIGHLIGHTING" />
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/8cb87e61921c49bcadb380781eccdeb4228600/16/829c32b2/NSwagApplicationBuilderExtensions.cs" root0="SKIP_HIGHLIGHTING" />
|
||||||
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/a24070ff7b2146de93398ba94af8728215800/ea/31248d29/AuthorizeAttribute.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/b13cb16f7e5749269b6922dc6752bd1c291448/5b/5bdfe94c/DatabaseFacade.cs" root0="FORCE_HIGHLIGHTING" />
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/b13cb16f7e5749269b6922dc6752bd1c291448/5b/5bdfe94c/DatabaseFacade.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/b9c62e8e682440cd84e6081f7672fcd3d1a000/61/073f2693/String.cs" root0="FORCE_HIGHLIGHTING" />
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/b9c62e8e682440cd84e6081f7672fcd3d1a000/61/073f2693/String.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/fe0eb928ebdd4a47ae76673fbad3037d10800/30/30f1fe01/IdentityUser`1.cs" root0="FORCE_HIGHLIGHTING" />
|
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/fe0eb928ebdd4a47ae76673fbad3037d10800/30/30f1fe01/IdentityUser`1.cs" root0="FORCE_HIGHLIGHTING" />
|
||||||
@@ -51,20 +64,21 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
<component name="PropertiesComponent">{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
".NET Launch Settings Profile.T120B165-ImgBoard: http.executor": "Run",
|
".NET Launch Settings Profile.T120B165-ImgBoard: http.executor": "Run",
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"git-widget-placeholder": "main",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"node.js.detected.package.eslint": "true",
|
"git-widget-placeholder": "main",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.detected.package.tslint": "true",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}]]></component>
|
}</component>
|
||||||
<component name="RunManager" selected=".NET Launch Settings Profile.T120B165-ImgBoard: http">
|
<component name="RunManager" selected=".NET Launch Settings Profile.T120B165-ImgBoard: http">
|
||||||
<configuration name="T120B165-ImgBoard: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
<configuration name="T120B165-ImgBoard: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/T120B165-ImgBoard/T120B165-ImgBoard.csproj" />
|
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/T120B165-ImgBoard/T120B165-ImgBoard.csproj" />
|
||||||
@@ -158,6 +172,13 @@
|
|||||||
<workItem from="1759305506296" duration="7211000" />
|
<workItem from="1759305506296" duration="7211000" />
|
||||||
<workItem from="1759334279107" duration="1064000" />
|
<workItem from="1759334279107" duration="1064000" />
|
||||||
<workItem from="1759446792755" duration="16407000" />
|
<workItem from="1759446792755" duration="16407000" />
|
||||||
|
<workItem from="1760458832335" duration="4091000" />
|
||||||
|
<workItem from="1760464175949" duration="802000" />
|
||||||
|
<workItem from="1760524309714" duration="6507000" />
|
||||||
|
<workItem from="1760900195475" duration="4137000" />
|
||||||
|
<workItem from="1761063773513" duration="10000" />
|
||||||
|
<workItem from="1762152955053" duration="832000" />
|
||||||
|
<workItem from="1762243840092" duration="121000" />
|
||||||
</task>
|
</task>
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
@@ -183,6 +204,19 @@
|
|||||||
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
|
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
|
||||||
<option name="timeStamp" value="3" />
|
<option name="timeStamp" value="3" />
|
||||||
</breakpoint>
|
</breakpoint>
|
||||||
|
<line-breakpoint enabled="true" type="DotNet Breakpoints">
|
||||||
|
<url>file://$PROJECT_DIR$/T120B165-ImgBoard/Services/PostService.cs</url>
|
||||||
|
<line>99</line>
|
||||||
|
<properties documentPath="$PROJECT_DIR$/T120B165-ImgBoard/Services/PostService.cs" containingFunctionPresentation="Method 'FindAll'">
|
||||||
|
<startOffsets>
|
||||||
|
<option value="3370" />
|
||||||
|
</startOffsets>
|
||||||
|
<endOffsets>
|
||||||
|
<option value="3417" />
|
||||||
|
</endOffsets>
|
||||||
|
</properties>
|
||||||
|
<option name="timeStamp" value="4" />
|
||||||
|
</line-breakpoint>
|
||||||
</breakpoints>
|
</breakpoints>
|
||||||
</breakpoint-manager>
|
</breakpoint-manager>
|
||||||
<pin-to-top-manager>
|
<pin-to-top-manager>
|
||||||
|
|||||||
4
Client/.dockerignore
Normal file
4
Client/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.react-router
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
README.md
|
||||||
7
Client/.gitignore
vendored
Normal file
7
Client/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# React Router
|
||||||
|
/.react-router/
|
||||||
|
/build/
|
||||||
22
Client/Dockerfile
Normal file
22
Client/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine AS development-dependencies-env
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS production-dependencies-env
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build-env
|
||||||
|
COPY . /app/
|
||||||
|
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build-env /app/build /app/build
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
89
Client/README.md
Normal file
89
Client/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
TODO: automatic refresh based on auth token expiration
|
||||||
|
|
||||||
|
# Welcome to React Router!
|
||||||
|
|
||||||
|
A modern, production-ready template for building full-stack React applications using React Router.
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Server-side rendering
|
||||||
|
- ⚡️ Hot Module Replacement (HMR)
|
||||||
|
- 📦 Asset bundling and optimization
|
||||||
|
- 🔄 Data loading and mutations
|
||||||
|
- 🔒 TypeScript by default
|
||||||
|
- 🎉 TailwindCSS for styling
|
||||||
|
- 📖 [React Router docs](https://reactrouter.com/)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Start the development server with HMR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Your application will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
Create a production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
To build and run using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t my-app .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 3000:3000 my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
The containerized application can be deployed to any platform that supports Docker, including:
|
||||||
|
|
||||||
|
- AWS ECS
|
||||||
|
- Google Cloud Run
|
||||||
|
- Azure Container Apps
|
||||||
|
- Digital Ocean App Platform
|
||||||
|
- Fly.io
|
||||||
|
- Railway
|
||||||
|
|
||||||
|
### DIY Deployment
|
||||||
|
|
||||||
|
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||||
|
|
||||||
|
Make sure to deploy the output of `npm run build`
|
||||||
|
|
||||||
|
```
|
||||||
|
├── package.json
|
||||||
|
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||||
|
├── build/
|
||||||
|
│ ├── client/ # Static assets
|
||||||
|
│ └── server/ # Server-side code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ using React Router.
|
||||||
146
Client/app/api/api.ts
Normal file
146
Client/app/api/api.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { User } from "~/context/AuthContext";
|
||||||
|
import type { Comment } from "~/types/comment";
|
||||||
|
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||||
|
import type { Post } from "~/types/post";
|
||||||
|
import type { Tag } from "~/types/tag";
|
||||||
|
|
||||||
|
const ENDPOINT = 'http://localhost:5259/api';
|
||||||
|
|
||||||
|
export class API {
|
||||||
|
|
||||||
|
private static async genericRequest(url: string, method: string, body: any = null, headers: Record<string, string> = {}): Promise<Response> {
|
||||||
|
const localHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
};
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: localHeaders,
|
||||||
|
};
|
||||||
|
if (body) {
|
||||||
|
options.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async get(url: string, headers: Record<string, string> = {}) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async post(url: string, body: any, headers: Record<string, string> = {}): Promise<Response> {
|
||||||
|
return await API.genericRequest(url, 'POST', body, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async patch(url: string, body: any, headers: Record<string, string> = {}): Promise<Response> {
|
||||||
|
return await API.genericRequest(url, 'PATCH', body, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async delete(url: string, body: any, headers: Record<string, string> = {}): Promise<Response> {
|
||||||
|
return await API.genericRequest(url, 'DELETE', body, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag APIs */
|
||||||
|
public static async fetchTags(page: number): Promise<PaginatedResponse<Tag>> {
|
||||||
|
const data = await API.get(`${ENDPOINT}/tags?page=${page}`);
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createTag(name: string, tagType: string, user: User): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/tags`, { name, type: tagType }, { Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateTag(name: string, newType: string, user: User): Promise<Response> {
|
||||||
|
return await API.patch(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, { type: newType }, { Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async deleteTag(name: string, user: User): Promise<Response> {
|
||||||
|
return await API.delete(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, {}, { Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Post APIs */
|
||||||
|
public static async fetchPostById(id: number): Promise<Post|null> {
|
||||||
|
const data = await API.get(`${ENDPOINT}/posts/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fetchPosts(page: number, filter: string = ""): Promise<PaginatedResponse<Post>> {
|
||||||
|
const data = await API.get(`${ENDPOINT}/posts?page=${page}&query=${encodeURIComponent(filter)}`);
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createPostRequest(
|
||||||
|
title: string, description: string,
|
||||||
|
tags: string[], filename: string,
|
||||||
|
fileMimeType: string, fileSize: number,
|
||||||
|
user: User
|
||||||
|
): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/posts`, {
|
||||||
|
title, description, tags, filename,
|
||||||
|
fileMimeType, fileSize
|
||||||
|
}, { Authorization: `Bearer ${user.accessToken}`})
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async patchFileUploadUrl(uploadUrl: string, image: File, user: User): Promise<Response> {
|
||||||
|
return await fetch(uploadUrl, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes 0-${image.size - 1}/${image.size}`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Authorization': `Bearer ${user.accessToken}`,
|
||||||
|
},
|
||||||
|
body: image,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updatePost(postId: number, title: string, description: string, tags: string[], user: User): Promise<Response> {
|
||||||
|
return await API.patch(`${ENDPOINT}/posts/${postId}`, { title, description, tags }, {Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async deletePost(postId: number, user: User): Promise<Response> {
|
||||||
|
return await API.delete(`${ENDPOINT}/posts/${postId}`, {}, {Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment APIs */
|
||||||
|
public static async fetchCommentsByPostId(postId: number, page: number): Promise<PaginatedResponse<Comment>> {
|
||||||
|
const data = await API.get(`${ENDPOINT}/posts/${postId}/comments?page=${page}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async postComment(postId: number, text: string, user: User): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/posts/${postId}/comments`, { text }, {Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateComment(postId: number, commentId: number, newText: string, user: User): Promise<Response> {
|
||||||
|
return await API.patch(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, { text: newText }, {Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async deleteComment(postId: number, commentId: number, user: User): Promise<Response> {
|
||||||
|
return await API.delete(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, {}, {Authorization: `Bearer ${user.accessToken}`});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Auth APIs */
|
||||||
|
public static async register(username: string, email: string, password: string): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/auth/register`, { username, email, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async login(email: string, password: string): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/auth/login`, { email, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async renewToken(refreshToken: string): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/auth/renew`, { refreshToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async revokeToken(refreshToken: string): Promise<Response> {
|
||||||
|
return await API.post(`${ENDPOINT}/auth/revoke`, { refreshToken });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
15
Client/app/app.css
Normal file
15
Client/app/app.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
@apply bg-white dark:bg-gray-950;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Client/app/components/Footer.tsx
Normal file
9
Client/app/components/Footer.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-white mt-auto">
|
||||||
|
<div className="container mx-auto py-4 px-4 text-center text-gray-500">
|
||||||
|
© {new Date().getFullYear()} ImageBoard. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
304
Client/app/components/Header.tsx
Normal file
304
Client/app/components/Header.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState, type FormEventHandler } from "react";
|
||||||
|
import Icon, { ICONS } from "./Icon";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { API } from "~/api/api";
|
||||||
|
import { useAuth } from "~/context/AuthContext";
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { user, login, logout, isLoading } = useAuth();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onBack = () => { navigate("/") };
|
||||||
|
const onUploadClick= () => setIsUploadModalOpen(true)
|
||||||
|
|
||||||
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
|
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||||
|
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const onTagsClick = () => { navigate("/tags") }
|
||||||
|
const onLoginClick = () => { setIsLoginModalOpen(true); }
|
||||||
|
const onRegisterClick = () => { setIsRegisterModalOpen(true); }
|
||||||
|
|
||||||
|
const onLogout = () => { logout(); if(isMobileMenuOpen) setIsMobileMenuOpen(false); }
|
||||||
|
|
||||||
|
type NavButtonProps = {
|
||||||
|
onClick: () => void;
|
||||||
|
icon: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NavButton = ({ onClick, icon, children, className = '' }: NavButtonProps) => (
|
||||||
|
<button onClick={onClick} className={`flex items-center space-x-2 px-4 py-2 font-semibold rounded-lg transition-colors ${className}`}>
|
||||||
|
<Icon path={icon} />
|
||||||
|
<span className="hidden md:inline">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
type MobileNavLinkProps = {
|
||||||
|
onClick: () => void;
|
||||||
|
icon: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileNavLink = ({ onClick, icon, children, className = '' }: MobileNavLinkProps) => (
|
||||||
|
<a href="#" onClick={(e) => { e.preventDefault(); onClick(); setIsMobileMenuOpen(false); }} className={`flex items-center space-x-3 px-3 py-2 text-base font-medium rounded-md ${className}`}>
|
||||||
|
<Icon path={icon} />
|
||||||
|
<span>{children}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UploadModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const enteredTags = tags.split(' ').filter(t => t);
|
||||||
|
|
||||||
|
if (!title || !imageFile || !description) {
|
||||||
|
setError('Please fill in all fields and select an image.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await API.createPostRequest(
|
||||||
|
title, description,
|
||||||
|
enteredTags, imageFile.name,
|
||||||
|
imageFile.type, imageFile.size,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.detail || `Upload failed with status ${response.status}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uploadUrl = data.fileUrl;
|
||||||
|
if (!uploadUrl) {
|
||||||
|
setError('Upload URL not provided by server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResponse = await API.patchFileUploadUrl(uploadUrl, imageFile, user);
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
setError(`Image upload failed with status ${uploadResponse.status}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/posts/${data.id}`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Upload a Post</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-title">Title</label>
|
||||||
|
<input id="post-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="e.g., My Awesome Picture"/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-description">Description</label>
|
||||||
|
<textarea id="post-description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="A short description of your image..."/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-tags">Tags (space-separated)</label>
|
||||||
|
<input id="post-tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="e.g., high-res"/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-image">Image</label>
|
||||||
|
<input id="post-image" type="file" accept="image/*" onChange={(e) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (files && files.length > 0) setImageFile(files[0])
|
||||||
|
}} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:bg-blue-300" disabled={!title || !imageFile || !description}>Upload</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RegisterModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await API.register(username, email, password);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.detail || "Registration failed. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically log in the user after successful registration
|
||||||
|
const loginResponse = await API.login(email, password);
|
||||||
|
if (loginResponse.ok) {
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
login(loginData.accessToken, loginData.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
setUsername('');
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Register</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-username">Username</label>
|
||||||
|
<input id="register-username" type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter username" required/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-email">Email</label>
|
||||||
|
<input id="register-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter email" required/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-password">Password</label>
|
||||||
|
<input id="register-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter password" required/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirm-password">Confirm Password</label>
|
||||||
|
<input id="confirm-password" type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" placeholder="Confirm password" required/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Register</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const response = await API.login(username, password);
|
||||||
|
if (!response.ok) {
|
||||||
|
setError("Login failed. Please check your credentials.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
login(data.accessToken, data.refreshToken);
|
||||||
|
onClose();
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isLoginModalOpen} onClose={onClose}>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Login</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">Email</label>
|
||||||
|
<input id="username" type="email" value={username} onChange={(e) => setUsername(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter email"/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">Password</label>
|
||||||
|
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter password"/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Sign In</button>
|
||||||
|
<a href="#" onClick={(e) => { e.preventDefault(); onClose(); onRegisterClick(); }} className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800">
|
||||||
|
Create an Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="bg-white shadow-md sticky top-0 z-10">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 cursor-pointer" onClick={onBack}>ImgBoard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Nav */}
|
||||||
|
<div className="hidden md:flex items-center space-x-2">
|
||||||
|
<NavButton onClick={onTagsClick} icon={ICONS.tags} className="text-gray-600 hover:bg-gray-100">Tags</NavButton>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-4 py-2 font-semibold">Loading...</div>
|
||||||
|
) : user ? (
|
||||||
|
<>
|
||||||
|
<NavButton onClick={onUploadClick} icon={ICONS.upload} className="text-gray-600 hover:bg-gray-100">Upload</NavButton>
|
||||||
|
<NavButton onClick={onLogout} icon={ICONS.logout} className="text-gray-600 hover:bg-gray-100">Logout</NavButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NavButton onClick={onLoginClick} icon={ICONS.login} className="bg-green-500 text-white hover:bg-green-600">Login</NavButton>
|
||||||
|
<NavButton onClick={onRegisterClick} icon={ICONS.register} className="bg-blue-500 text-white hover:bg-blue-600">Register</NavButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<button onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} className="p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100">
|
||||||
|
<Icon path={isMobileMenuOpen ? ICONS.close : ICONS.menu} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="md:hidden absolute top-16 left-0 right-0 bg-white shadow-lg p-2 z-20">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<MobileNavLink onClick={onTagsClick} icon={ICONS.tags} className="text-gray-600 hover:bg-gray-100">Tags</MobileNavLink>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-3 py-2">Loading...</div>
|
||||||
|
) : user ? (
|
||||||
|
<>
|
||||||
|
<MobileNavLink onClick={onUploadClick} icon={ICONS.upload} className="text-gray-700 hover:bg-gray-100">Upload Image</MobileNavLink>
|
||||||
|
<MobileNavLink onClick={onLogout} icon={ICONS.logout} className="text-red-600 hover:bg-red-50">Logout</MobileNavLink>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MobileNavLink onClick={onLoginClick} icon={ICONS.login} className="text-green-600 hover:bg-green-50">Login</MobileNavLink>
|
||||||
|
<MobileNavLink onClick={onRegisterClick} icon={ICONS.register} className="text-blue-600 hover:bg-blue-50">Register</MobileNavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<LoginModal isOpen={isLoginModalOpen} onClose={() => setIsLoginModalOpen(false)}/>
|
||||||
|
<RegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)}/>
|
||||||
|
<UploadModal isOpen={isUploadModalOpen} onClose={() => setIsUploadModalOpen(false)}/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
Client/app/components/Icon.tsx
Normal file
29
Client/app/components/Icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const ICONS = {
|
||||||
|
back: "M15 19l-7-7 7-7",
|
||||||
|
close: "M6 18L18 6M6 6l12 12",
|
||||||
|
menu: "M4 6h16M4 12h16M4 18h16",
|
||||||
|
search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||||
|
edit: "M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L15.232 5.232z",
|
||||||
|
delete: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16",
|
||||||
|
viewRaw: "M15 12a3 3 0 11-6 0 3 3 0 016 0zM2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
||||||
|
admin: "M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.186 0-6.149 2.08-7.938 5.124z",
|
||||||
|
upload: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12",
|
||||||
|
add: "M12 6v12m6-6H6",
|
||||||
|
login: "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75",
|
||||||
|
logout: "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9",
|
||||||
|
tags: "M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3zM11.25 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3z",
|
||||||
|
register: "M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||||
|
};
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
path: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Icon({ path, className = "h-6 w-6" }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
Client/app/components/Loading.tsx
Normal file
5
Client/app/components/Loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return <div className="flex justify-center items-center h-64">
|
||||||
|
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-16 w-16"></div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
21
Client/app/components/Modal.tsx
Normal file
21
Client/app/components/Modal.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Icon, { ICONS } from "./Icon";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, children }: ModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6 relative">
|
||||||
|
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600">
|
||||||
|
<Icon path={ICONS.close} />
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
Client/app/components/NotFound.tsx
Normal file
9
Client/app/components/NotFound.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
|
||||||
|
<h1 className="text-6xl font-bold text-gray-800 mb-4">404</h1>
|
||||||
|
<p className="text-xl text-gray-600 mb-8">The page you are looking for does not exist.</p>
|
||||||
|
<a href="/" className="text-blue-500 hover:underline">Go back to Home</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
Client/app/context/AuthContext.tsx
Normal file
90
Client/app/context/AuthContext.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (accessToken: string, refreshToken: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
login: () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("user");
|
||||||
|
if (stored) {
|
||||||
|
const storedUser: User = JSON.parse(stored);
|
||||||
|
try {
|
||||||
|
const decoded: { exp: number } = jwtDecode(storedUser.accessToken);
|
||||||
|
if (decoded.exp * 1000 < Date.now()) {
|
||||||
|
// Token is expired, clear user data
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
setUser(null);
|
||||||
|
} else {
|
||||||
|
setUser(storedUser);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If token is invalid, clear user data
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = (accessToken: string, refreshToken: string) => {
|
||||||
|
const decoded: any = jwtDecode(accessToken);
|
||||||
|
const roleField = decoded["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
|
||||||
|
const isAdmin = Array.isArray(roleField) ? roleField.includes("Admin") : roleField === "Admin";
|
||||||
|
const newUser: User = {
|
||||||
|
id: decoded.sub,
|
||||||
|
email: decoded.email,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
isAdmin,
|
||||||
|
};
|
||||||
|
setUser(newUser);
|
||||||
|
localStorage.setItem("user", JSON.stringify(newUser));
|
||||||
|
localStorage.setItem("accessToken", accessToken);
|
||||||
|
localStorage.setItem("refreshToken", refreshToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
14
Client/app/layout/RootLayout.tsx
Normal file
14
Client/app/layout/RootLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Footer from "~/components/Footer";
|
||||||
|
import Header from "~/components/Header";
|
||||||
|
|
||||||
|
export default function RootLayout({children}: {children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-100 min-h-screen font-sans flex flex-col">
|
||||||
|
<Header/>
|
||||||
|
<main className="container mx-auto pb-8 flex-grow">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
Client/app/root.tsx
Normal file
78
Client/app/root.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
isRouteErrorResponse,
|
||||||
|
Links,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "react-router";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/root";
|
||||||
|
import "./app.css";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
|
||||||
|
export const links: Route.LinksFunction = () => [
|
||||||
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.gstatic.com",
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <AuthProvider>
|
||||||
|
<Outlet />
|
||||||
|
</AuthProvider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
let message = "Oops!";
|
||||||
|
let details = "An unexpected error occurred.";
|
||||||
|
let stack: string | undefined;
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
message = error.status === 404 ? "404" : "Error";
|
||||||
|
details =
|
||||||
|
error.status === 404
|
||||||
|
? "The requested page could not be found."
|
||||||
|
: error.statusText || details;
|
||||||
|
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||||
|
details = error.message;
|
||||||
|
stack = error.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="pt-16 p-4 container mx-auto">
|
||||||
|
<h1>{message}</h1>
|
||||||
|
<p>{details}</p>
|
||||||
|
{stack && (
|
||||||
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
|
<code>{stack}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
Client/app/routes.ts
Normal file
8
Client/app/routes.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
index("routes/posts.tsx"),
|
||||||
|
//route("posts", "routes/posts/posts.tsx"),
|
||||||
|
route("posts/:id", "routes/post.tsx"),
|
||||||
|
route("tags", "routes/tags.tsx"),
|
||||||
|
] satisfies RouteConfig;
|
||||||
392
Client/app/routes/post.tsx
Normal file
392
Client/app/routes/post.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import RootLayout from "~/layout/RootLayout";
|
||||||
|
import type { Route } from "../+types/root";
|
||||||
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
import { useEffect, useState, type FormEventHandler } from "react";
|
||||||
|
import { API } from "~/api/api";
|
||||||
|
import Icon, { ICONS } from "~/components/Icon";
|
||||||
|
import { getTagColor } from "../utils/tags";
|
||||||
|
|
||||||
|
import type { Post } from "~/types/post";
|
||||||
|
import Modal from "~/components/Modal";
|
||||||
|
import type { Comment } from "~/types/comment";
|
||||||
|
import Loading from "~/components/Loading";
|
||||||
|
import { useAuth } from "~/context/AuthContext";
|
||||||
|
import { NotFound } from "~/components/NotFound";
|
||||||
|
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||||
|
|
||||||
|
type ActionButtonProps = {
|
||||||
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
icon: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta({}: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "ImgBoard - Viewing Post" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Post() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <NotFound/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const idNum = parseInt(id, 10);
|
||||||
|
if (isNaN(idNum)) {
|
||||||
|
return <NotFound/>
|
||||||
|
}
|
||||||
|
|
||||||
|
const [post, setPost] = useState<Post|null>(null);
|
||||||
|
const [isEditPostModalOpen, setIsEditPostModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [paginatedComments, setPaginatedComments] = useState<PaginatedResponse<Comment> | null>(null);
|
||||||
|
const [currentCommentPage, setCurrentCommentPage] = useState(1);
|
||||||
|
const [commentToEdit, setCommentToEdit] = useState<Comment|null>(null);
|
||||||
|
const [isEditCommentModalOpen, setIsEditCommentModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
console.log("Post ID from params:", id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch post details using the id
|
||||||
|
|
||||||
|
console.log(`Fetching post with id: ${id}`);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
const post = await API.fetchPostById(idNum);
|
||||||
|
setPost(post);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchComments() {
|
||||||
|
const comments = await API.fetchCommentsByPostId(idNum, currentCommentPage);
|
||||||
|
setPaginatedComments(comments);
|
||||||
|
}
|
||||||
|
fetchComments();
|
||||||
|
}, [id, currentCommentPage]);
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return <RootLayout>
|
||||||
|
<Loading />
|
||||||
|
</RootLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button component for actions like Edit, Delete, View Raw, etc.
|
||||||
|
*/
|
||||||
|
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, icon, children, className }) => (
|
||||||
|
<button onClick={onClick} className={`flex items-center space-x-1.5 font-semibold px-3 py-1.5 rounded-md hover:bg-opacity-80 transition-colors text-sm ${className}`}>
|
||||||
|
<Icon path={icon} className="w-4 h-4" />
|
||||||
|
<span>{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Pagination = ({
|
||||||
|
itemsPerPage,
|
||||||
|
totalItems,
|
||||||
|
paginate,
|
||||||
|
currentPage,
|
||||||
|
}: {
|
||||||
|
itemsPerPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
paginate: (pageNumber: number) => void;
|
||||||
|
currentPage: number;
|
||||||
|
}) => {
|
||||||
|
const pageNumbers = [];
|
||||||
|
for (let i = 1; i <= Math.ceil(totalItems / itemsPerPage); i++) { pageNumbers.push(i); }
|
||||||
|
if(pageNumbers.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<nav className="mt-4 flex justify-center">
|
||||||
|
<ul className="flex items-center space-x-2">
|
||||||
|
{pageNumbers.map(number => (
|
||||||
|
<li key={number}>
|
||||||
|
<button onClick={() => paginate(number)} className={`px-3 py-1 rounded-md transition-colors text-sm ${ currentPage === number ? 'bg-blue-500 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
|
||||||
|
{number}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditPostModal = () => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (post) {
|
||||||
|
setTitle(post.title);
|
||||||
|
setDescription(post.description);
|
||||||
|
setTags(post.tags.map(t => t.name).join(' '));
|
||||||
|
}
|
||||||
|
}, [post]);
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!user) return;
|
||||||
|
const enteredTags = tags.split(' ').filter(t => t);
|
||||||
|
|
||||||
|
if (title && description && tags) {
|
||||||
|
const res = await API.updatePost(post.id, title, description, enteredTags, user);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.detail || "Failed to update post.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPost(data);
|
||||||
|
setIsEditPostModalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isEditPostModalOpen} onClose={() => setIsEditPostModalOpen(false)}>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Edit Post</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-title">Title</label>
|
||||||
|
<input id="edit-post-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-description">Description</label>
|
||||||
|
<textarea id="edit-post-description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-tags">Tags</label>
|
||||||
|
<input id="edit-post-tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canManagePost = user && (user.isAdmin || user.id === post.author.userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a button to edit a post is clicked.
|
||||||
|
*/
|
||||||
|
const onOpenEditPostModal = () => {
|
||||||
|
setIsEditPostModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a button to delete a post is clicked.
|
||||||
|
*/
|
||||||
|
const onPostDelete = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const res = await API.deletePost(post.id, user)
|
||||||
|
if (!res.ok) {
|
||||||
|
alert("Failed to delete post.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigate('/')
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const onAddComment = async (postId: number, commentText: string) => {
|
||||||
|
if (!user || !paginatedComments) return;
|
||||||
|
const res = await API.postComment(postId, commentText, user);
|
||||||
|
if (!res.ok) {
|
||||||
|
alert("Failed to add comment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newComment: Comment = await res.json();
|
||||||
|
|
||||||
|
const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize);
|
||||||
|
if (currentCommentPage !== totalPages) {
|
||||||
|
setCurrentCommentPage(totalPages);
|
||||||
|
} else {
|
||||||
|
setPaginatedComments(prev => {
|
||||||
|
if (!prev) return null;
|
||||||
|
// If current page is not full, just add the comment
|
||||||
|
if (prev.items.length < prev.pageSize) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: [...prev.items, newComment],
|
||||||
|
total: prev.totalCount + 1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch.
|
||||||
|
setCurrentCommentPage(totalPages);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditCommentModal = (comment: Comment) => {
|
||||||
|
setCommentToEdit(comment);
|
||||||
|
setIsEditCommentModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteComment = async (postId: number, commentId: number) => {
|
||||||
|
if (!user || !paginatedComments) return;
|
||||||
|
const res = await API.deleteComment(postId, commentId, user);
|
||||||
|
if (!res.ok) {
|
||||||
|
alert("Failed to delete comment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaginatedComments(prev => {
|
||||||
|
if (!prev) return null;
|
||||||
|
const newItems = prev.items.filter(c => c.id !== commentId);
|
||||||
|
if (newItems.length === 0 && currentCommentPage > 1) {
|
||||||
|
setCurrentCommentPage(currentCommentPage - 1);
|
||||||
|
}
|
||||||
|
return { ...prev, items: newItems, total: prev.totalCount - 1 };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//const allTags = [...new Set(posts.flatMap(p => p.tags))];
|
||||||
|
|
||||||
|
const CommentForm = () => {
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
|
setError('');
|
||||||
|
e.preventDefault();
|
||||||
|
if (!commentText.trim()) return;
|
||||||
|
if (!user) return;
|
||||||
|
await onAddComment(post.id, commentText);
|
||||||
|
setCommentText('');
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6">
|
||||||
|
<textarea className="w-full border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-blue-500 transition-colors" rows={3} placeholder="Write a comment..." value={commentText} onChange={(e) => setCommentText(e.target.value)}></textarea>
|
||||||
|
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||||
|
<button type="submit" className="mt-2 bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors disabled:bg-blue-300" disabled={!commentText.trim()}>Post Comment</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditCommentModal = () => {
|
||||||
|
const [newText, setNewText] = useState(commentToEdit?.text || '');
|
||||||
|
useEffect(() => {
|
||||||
|
if (commentToEdit) {
|
||||||
|
setNewText(commentToEdit.text);
|
||||||
|
}
|
||||||
|
}, [commentToEdit]);
|
||||||
|
const handleSubmit: FormEventHandler = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newText && commentToEdit && newText !== commentToEdit.text && user) {
|
||||||
|
// TODO
|
||||||
|
const res = await API.updateComment(post.id, commentToEdit.id, newText, user);
|
||||||
|
if (!res.ok) {
|
||||||
|
alert("Failed to update comment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPaginatedComments(prev => prev ? { ...prev, items: prev.items.map(c => c.id === commentToEdit.id ? {...c, text: newText} : c) } : null);
|
||||||
|
}
|
||||||
|
setIsEditCommentModalOpen(false)
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isEditCommentModalOpen} onClose={() => setIsEditCommentModalOpen(false)}>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Edit Comment</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<textarea value={newText} onChange={(e) => setNewText(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" rows={4}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootLayout>
|
||||||
|
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<img src={post.fileUrl} alt={post.title} className="w-full h-auto object-cover max-h-[75vh]" />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between sm:items-start gap-4">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 break-words">{post.title}</h2>
|
||||||
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||||
|
<a href={post.fileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ActionButton icon={ICONS.viewRaw} className="bg-gray-200 text-gray-800">
|
||||||
|
Raw
|
||||||
|
</ActionButton>
|
||||||
|
</a>
|
||||||
|
{canManagePost && (
|
||||||
|
<>
|
||||||
|
<ActionButton onClick={() => onOpenEditPostModal()} icon={ICONS.edit} className="bg-yellow-500 text-white">Edit</ActionButton>
|
||||||
|
<ActionButton onClick={() => onPostDelete()} icon={ICONS.delete} className="bg-red-500 text-white">Delete</ActionButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 my-4">
|
||||||
|
{post.tags.map(tag => (
|
||||||
|
<span key={tag.name} className={`text-sm font-medium px-2.5 py-0.5 rounded-full ${getTagColor(tag)}`}>{tag.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-md text-gray-600 mb-4">Posted by <span className="font-semibold">{post.author.userName}</span> • {formatDate(post.createdAt)}</p>
|
||||||
|
<p className="text-gray-700 bg-gray-50 p-4 rounded-md whitespace-pre-wrap">{post.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4 text-gray-700 border-b pb-2">Comments ({paginatedComments?.totalCount ?? 0})</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!paginatedComments ? <Loading/> : paginatedComments.items.length > 0 ? (
|
||||||
|
paginatedComments.items.map(comment => {
|
||||||
|
const canManageComment = user && (user.isAdmin || user.id === comment.author.userId);
|
||||||
|
return (
|
||||||
|
<div key={comment.id} className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<p className="font-semibold text-gray-800">{comment.author.userName}</p>
|
||||||
|
{canManageComment && (
|
||||||
|
<div className="space-x-2">
|
||||||
|
<button onClick={() => handleOpenEditCommentModal(comment)} className="text-xs font-semibold text-yellow-600 hover:underline">Edit</button>
|
||||||
|
<button onClick={() => onDeleteComment(post.id, comment.id)} className="text-xs font-semibold text-red-600 hover:underline">Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 whitespace-pre-wrap">{comment.text}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : ( <p className="text-gray-500">No comments yet. Be the first to share your thoughts!</p> )}
|
||||||
|
</div>
|
||||||
|
{paginatedComments && <Pagination itemsPerPage={paginatedComments.pageSize} totalItems={paginatedComments.totalCount} paginate={setCurrentCommentPage} currentPage={currentCommentPage} />}
|
||||||
|
{user && <CommentForm/>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditPostModal/>
|
||||||
|
<EditCommentModal/>
|
||||||
|
</RootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
Client/app/routes/posts.tsx
Normal file
155
Client/app/routes/posts.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
|
||||||
|
import type { Route } from "../+types/root";
|
||||||
|
import { useState, useEffect, type FormEventHandler } from "react";
|
||||||
|
|
||||||
|
import RootLayout from "~/layout/RootLayout";
|
||||||
|
import { getTagColor } from "../utils/tags";
|
||||||
|
import type { Post } from "../types/post";
|
||||||
|
|
||||||
|
import Icon, { ICONS } from "../components/Icon";
|
||||||
|
|
||||||
|
import { API } from "../api/api";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||||
|
import Loading from "~/components/Loading";
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
post: Post;
|
||||||
|
onClick: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meta({}: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "ImgBoard - Posts" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Posts() {
|
||||||
|
|
||||||
|
const PostCard = ({ post, onClick }: PostCardProps) => (
|
||||||
|
<div className="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transform hover:-translate-y-1 transition-transform duration-300" onClick={() => onClick(post.id)}>
|
||||||
|
<img src={post.fileUrl} alt={post.title} className="w-full h-48 object-cover" />
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-bold text-lg truncate">{post.title}</h3>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{post.tags.slice(0, 3).map(tag => (
|
||||||
|
<span key={tag.name} className={`text-xs font-semibold px-2 py-1 rounded-full ${getTagColor(tag)}`}>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{post.tags.length > 3 && <span className="text-gray-500 text-xs font-semibold py-1">...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const SearchBar = ({ currentQuery, onSearch }: { currentQuery: string, onSearch: (query: string) => void }) => {
|
||||||
|
const [query, setQuery] = useState(currentQuery);
|
||||||
|
|
||||||
|
const handleSearch: FormEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:px-6 md:pt-6">
|
||||||
|
<form onSubmit={handleSearch} className="max-w-xl mx-auto">
|
||||||
|
<div className="relative">
|
||||||
|
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts by tags (e.g., +high-res -theotown)" className="w-full px-5 py-3 text-gray-700 bg-white border-2 border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"/>
|
||||||
|
<button type="submit" className="absolute top-0 right-0 mt-3 mr-4 text-gray-400 hover:text-gray-600">
|
||||||
|
<Icon path={ICONS.search}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GridView = ({ posts, onPostSelect }: { posts: Post[]; onPostSelect: (id: number) => void }) => (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-4 md:p-6">
|
||||||
|
{posts.map(post => <PostCard key={post.id} post={post} onClick={onPostSelect} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Pagination = ({
|
||||||
|
postsPerPage,
|
||||||
|
totalPosts,
|
||||||
|
paginate,
|
||||||
|
currentPage,
|
||||||
|
}: {
|
||||||
|
postsPerPage: number;
|
||||||
|
totalPosts: number;
|
||||||
|
paginate: (pageNumber: number) => void;
|
||||||
|
currentPage: number;
|
||||||
|
}) => {
|
||||||
|
const pageNumbers = [];
|
||||||
|
for (let i = 1; i <= Math.ceil(totalPosts / postsPerPage); i++) { pageNumbers.push(i); }
|
||||||
|
if(pageNumbers.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<nav className="mt-8 flex justify-center">
|
||||||
|
<ul className="flex items-center space-x-2">
|
||||||
|
{pageNumbers.map(number => (
|
||||||
|
<li key={number}>
|
||||||
|
<button onClick={() => paginate(number)} className={`px-4 py-2 rounded-md transition-colors ${ currentPage === number ? 'bg-blue-500 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
|
||||||
|
{number}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [paginatedData, setPaginatedData] = useState<PaginatedResponse<Post> | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
const data = await API.fetchPosts(currentPage, searchQuery);
|
||||||
|
setPaginatedData(data);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, [currentPage, searchQuery]);
|
||||||
|
|
||||||
|
const handleSearch = (query: string) => { setSearchQuery(query); setCurrentPage(1); };
|
||||||
|
const handlePostSelect = (id: number) => { navigate(`/posts/${id}`) };
|
||||||
|
|
||||||
|
const currentPosts = paginatedData?.items ?? [];
|
||||||
|
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||||
|
|
||||||
|
if (!paginatedData) {
|
||||||
|
return <RootLayout>
|
||||||
|
<Loading />
|
||||||
|
</RootLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootLayout>
|
||||||
|
{paginatedData.items.length > 0 ?
|
||||||
|
<>
|
||||||
|
<SearchBar currentQuery={searchQuery} onSearch={handleSearch} />
|
||||||
|
<GridView posts={currentPosts} onPostSelect={handlePostSelect} />
|
||||||
|
<Pagination postsPerPage={paginatedData.pageSize} totalPosts={paginatedData.totalCount} paginate={paginate} currentPage={currentPage} />
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||||
|
<div className="bg-white rounded-xl shadow-lg p-6 text-center">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-gray-800">No Posts Found</h2>
|
||||||
|
<p className="text-gray-600 mb-6">Try adjusting your search to find what you're looking for.</p>
|
||||||
|
<button onClick={() => { setSearchQuery(''); setCurrentPage(1); }} className="bg-blue-500 text-white font-semibold px-4 py-2 rounded-md hover:bg-blue-600 transition-colors">
|
||||||
|
Clear Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</RootLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
Client/app/types/PaginatedResponse.tsx
Normal file
7
Client/app/types/PaginatedResponse.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
items: T[];
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
7
Client/app/types/comment.tsx
Normal file
7
Client/app/types/comment.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { SlimUser } from "./user"
|
||||||
|
|
||||||
|
export type Comment = {
|
||||||
|
id: number
|
||||||
|
text: string
|
||||||
|
author: SlimUser
|
||||||
|
}
|
||||||
12
Client/app/types/post.tsx
Normal file
12
Client/app/types/post.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Tag } from "./tag"
|
||||||
|
import type { SlimUser } from "./user"
|
||||||
|
|
||||||
|
export type Post = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
author: SlimUser
|
||||||
|
fileUrl: string
|
||||||
|
tags: Tag[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
9
Client/app/types/tag.tsx
Normal file
9
Client/app/types/tag.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export enum TagType {
|
||||||
|
General = "General",
|
||||||
|
Copyright = "Copyright"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
name: string
|
||||||
|
type: TagType
|
||||||
|
}
|
||||||
4
Client/app/types/user.tsx
Normal file
4
Client/app/types/user.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type SlimUser = {
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
}
|
||||||
9
Client/app/utils/tags.tsx
Normal file
9
Client/app/utils/tags.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Tag } from "~/types/tag";
|
||||||
|
|
||||||
|
export const getTagColor = (tag: Tag) => {
|
||||||
|
const categories = {
|
||||||
|
"General": 'bg-green-100 text-green-800',
|
||||||
|
"Copyright": 'bg-yellow-100 text-yellow-800',
|
||||||
|
};
|
||||||
|
return categories[tag.type] || 'bg-gray-200 text-gray-800';
|
||||||
|
}
|
||||||
4828
Client/package-lock.json
generated
Normal file
4828
Client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Client/package.json
Normal file
31
Client/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "react-router build",
|
||||||
|
"dev": "react-router dev",
|
||||||
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
|
"typecheck": "react-router typegen && tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-router/node": "^7.9.2",
|
||||||
|
"@react-router/serve": "^7.9.2",
|
||||||
|
"isbot": "^5.1.31",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router": "^7.9.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@react-router/dev": "^7.9.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Client/public/favicon.ico
Normal file
BIN
Client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
Client/react-router.config.ts
Normal file
7
Client/react-router.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Config options...
|
||||||
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
|
ssr: true,
|
||||||
|
} satisfies Config;
|
||||||
27
Client/tsconfig.json
Normal file
27
Client/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"**/*",
|
||||||
|
"**/.server/**/*",
|
||||||
|
"**/.client/**/*",
|
||||||
|
".react-router/types/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"types": ["node", "vite/client"],
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"rootDirs": [".", "./.react-router/types"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./app/*"]
|
||||||
|
},
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Client/vite.config.ts
Normal file
8
Client/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||||
|
});
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# ImgBoard
|
# ImgBoard
|
||||||
|
|
||||||
**TODO: move refresh token to be JWT**
|
|
||||||
|
|
||||||
## Sistemos paskirtis
|
## Sistemos paskirtis
|
||||||
Projekto tikslas - sukurti nuotraukų dalinimosi platformą, kuri leistų naudotojams įkelti, peržiūrėti ir komentuoti nuotraukas.
|
Projekto tikslas - sukurti nuotraukų dalinimosi platformą, kuri leistų naudotojams įkelti, peržiūrėti ir komentuoti nuotraukas.
|
||||||
|
|
||||||
@@ -18,8 +16,6 @@ Sistema bus kuriama naudojant modernias technologijas, o diegimas bus supaprasti
|
|||||||
|
|
||||||
#### Rolių reikalavimai
|
#### Rolių reikalavimai
|
||||||
|
|
||||||
**! Nubraukti rolių reikalavimai yra kol kas nėra įgyvendinti.**
|
|
||||||
|
|
||||||
**Svečiai**
|
**Svečiai**
|
||||||
1. Gali peržiūrėti visus įkeltus paveikslus.
|
1. Gali peržiūrėti visus įkeltus paveikslus.
|
||||||
2. Gali matyti visus komentarus po paveikslais.
|
2. Gali matyti visus komentarus po paveikslais.
|
||||||
@@ -35,7 +31,6 @@ Sistema bus kuriama naudojant modernias technologijas, o diegimas bus supaprasti
|
|||||||
6. Gali rašyti komentarus po paveikslais.
|
6. Gali rašyti komentarus po paveikslais.
|
||||||
7. Gali redaguoti savo komentarus.
|
7. Gali redaguoti savo komentarus.
|
||||||
8. Gali ištrinti savo komentarus.
|
8. Gali ištrinti savo komentarus.
|
||||||
9. ~~Gali keisti savo paskyros informaciją (slaptažodį, el. paštą).~~
|
|
||||||
|
|
||||||
**Administratoriai**
|
**Administratoriai**
|
||||||
1. Turi teisę šalinti ir redaguoti kitų naudotojų paveikslus bei komentarus.
|
1. Turi teisę šalinti ir redaguoti kitų naudotojų paveikslus bei komentarus.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthorizeAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa24070ff7b2146de93398ba94af8728215800_003Fea_003F31248d29_003FAuthorizeAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADatabaseFacade_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb13cb16f7e5749269b6922dc6752bd1c291448_003F5b_003F5bdfe94c_003FDatabaseFacade_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADatabaseFacade_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb13cb16f7e5749269b6922dc6752bd1c291448_003F5b_003F5bdfe94c_003FDatabaseFacade_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpRequest_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc0938152eafe4b3e9479be2386b1a5c24b000_003Fb4_003F66f78bb3_003FHttpRequest_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityDbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Ff9342fb513b7b524925c79d9822cd3ecaf072c50231aa3b469875f4c2cdf_003FIdentityDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityDbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Ff9342fb513b7b524925c79d9822cd3ecaf072c50231aa3b469875f4c2cdf_003FIdentityDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe0eb928ebdd4a47ae76673fbad3037d10800_003F9f_003Fa723ee44_003FIdentityUser_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe0eb928ebdd4a47ae76673fbad3037d10800_003F9f_003Fa723ee44_003FIdentityUser_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe0eb928ebdd4a47ae76673fbad3037d10800_003F30_003F30f1fe01_003FIdentityUser_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIdentityUser_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe0eb928ebdd4a47ae76673fbad3037d10800_003F30_003F30f1fe01_003FIdentityUser_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
|||||||
@@ -328,15 +328,15 @@ public class PostController(
|
|||||||
/// Get a paginated list of posts.
|
/// Get a paginated list of posts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="query">Query to filter posts by tags. Use +tag-name to require a tag, -tag-name to exclude a tag.</param>
|
/// <param name="query">Query to filter posts by tags. Use +tag-name to require a tag, -tag-name to exclude a tag.</param>
|
||||||
/// <param name="pageNumber">The page number.</param>
|
/// <param name="page">The page number.</param>
|
||||||
/// <response code="200">The paginated list</response>
|
/// <response code="200">The paginated list</response>
|
||||||
/// <response code="400">If request is malformed</response>
|
/// <response code="400">If request is malformed</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<PagedList<PostDto>>> GetAll(string? query, [Range(1, int.MaxValue)] int pageNumber = 1)
|
public async Task<ActionResult<PagedList<PostDto>>> GetAll(string? query, [Range(1, int.MaxValue)] int page = 1)
|
||||||
{
|
{
|
||||||
var list = await postService.FindAll(query, pageNumber);
|
var list = await postService.FindAll(query, page);
|
||||||
var newItems = list.Items.Select(i =>
|
var newItems = list.Items.Select(i =>
|
||||||
{
|
{
|
||||||
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
|
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
|
||||||
@@ -494,7 +494,7 @@ public class PostController(
|
|||||||
/// Get paginated list of specific post comments.
|
/// Get paginated list of specific post comments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="postId">Post ID</param>
|
/// <param name="postId">Post ID</param>
|
||||||
/// <param name="pageNumber">Page number</param>
|
/// <param name="page">Page number</param>
|
||||||
/// <response code="200">Paginated list of comments</response>
|
/// <response code="200">Paginated list of comments</response>
|
||||||
/// <response code="400">If request is malformed</response>
|
/// <response code="400">If request is malformed</response>
|
||||||
/// <response code="404">If post is not found</response>
|
/// <response code="404">If post is not found</response>
|
||||||
@@ -504,13 +504,13 @@ public class PostController(
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<PagedList<CommentDto>>> GetAllComments(
|
public async Task<ActionResult<PagedList<CommentDto>>> GetAllComments(
|
||||||
int postId,
|
int postId,
|
||||||
[Range(1, int.MaxValue)] int pageNumber = 1
|
[Range(1, int.MaxValue)] int page = 1
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var post = await postService.GetById(postId);
|
var post = await postService.GetById(postId);
|
||||||
if (post == null) return NotFound();
|
if (post == null) return NotFound();
|
||||||
|
|
||||||
var list = await commentService.GetAll(postId, pageNumber);
|
var list = await commentService.GetAll(postId, page);
|
||||||
var newItems = list.Items.Select(CommentDto.FromComment).ToList();
|
var newItems = list.Items.Select(CommentDto.FromComment).ToList();
|
||||||
return Ok(new PagedList<CommentDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
|
return Ok(new PagedList<CommentDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class TagController(
|
|||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<Tag>> Create(CreateTagDto dto)
|
public async Task<ActionResult<TagDto>> Create(CreateTagDto dto)
|
||||||
{
|
{
|
||||||
// Check if tag exists, if it does, throw a conflict
|
// Check if tag exists, if it does, throw a conflict
|
||||||
var existingTag = await tagService.GetByName(dto.Name);
|
var existingTag = await tagService.GetByName(dto.Name);
|
||||||
@@ -44,21 +44,23 @@ public class TagController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var createdTag = await tagService.Create(dto.Type, dto.Name);
|
var createdTag = await tagService.Create(dto.Type, dto.Name);
|
||||||
return CreatedAtAction(nameof(Get), new { name = createdTag.Name }, createdTag);
|
return CreatedAtAction(nameof(Get), new { name = createdTag.Name }, TagDto.FromTag(createdTag));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get a paginated list of tags.
|
/// Get a paginated list of tags.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pageNumber">The page number</param>
|
/// <param name="page">The page number</param>
|
||||||
/// <response code="200">Returns paginated list</response>
|
/// <response code="200">Returns paginated list</response>
|
||||||
/// <response code="400">If request is malformed</response>
|
/// <response code="400">If request is malformed</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<PagedList<Tag>>> GetAll(int pageNumber = 1)
|
public async Task<ActionResult<PagedList<TagDto>>> GetAll(int page = 1)
|
||||||
{
|
{
|
||||||
return Ok(await tagService.GetAll(pageNumber));
|
var list = await tagService.GetAll(page);
|
||||||
|
var newItems = list.Items.Select(TagDto.FromTag).ToList();
|
||||||
|
return Ok(new PagedList<TagDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,14 +74,14 @@ public class TagController(
|
|||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<Tag>> Get(string name)
|
public async Task<ActionResult<TagDto>> Get(string name)
|
||||||
{
|
{
|
||||||
var tag = await tagService.GetByName(name);
|
var tag = await tagService.GetByName(name);
|
||||||
if (tag == null)
|
if (tag == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
return Ok(tag);
|
return Ok(TagDto.FromTag(tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -122,12 +124,12 @@ public class TagController(
|
|||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<Tag>> Update(string name, EditTagDto dto)
|
public async Task<ActionResult<TagDto>> Update(string name, EditTagDto dto)
|
||||||
{
|
{
|
||||||
var tag = await tagService.GetByName(name);
|
var tag = await tagService.GetByName(name);
|
||||||
if (tag == null) return NotFound();
|
if (tag == null) return NotFound();
|
||||||
var updatedTag = await tagService.Update(tag, dto.Type);
|
var updatedTag = await tagService.Update(tag, dto.Type);
|
||||||
return Ok(updatedTag);
|
return Ok(TagDto.FromTag(updatedTag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
namespace T120B165_ImgBoard.Dtos.Comment;
|
namespace T120B165_ImgBoard.Dtos.Comment;
|
||||||
|
|
||||||
public record CommentDto(int Id, string Text, SlimUserDto Author)
|
public record CommentDto(int Id, string Text, SlimUserDto Author, DateTime CreatedAt)
|
||||||
{
|
{
|
||||||
public static CommentDto FromComment(Models.Comment comment)
|
public static CommentDto FromComment(Models.Comment comment)
|
||||||
{
|
{
|
||||||
return new CommentDto(
|
return new CommentDto(
|
||||||
Id: comment.Id,
|
Id: comment.Id,
|
||||||
Text: comment.Text,
|
Text: comment.Text,
|
||||||
Author: SlimUserDto.FromUser(comment.Author)
|
Author: SlimUserDto.FromUser(comment.Author),
|
||||||
|
CreatedAt: comment.Created
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using T120B165_ImgBoard.Dtos.Tag;
|
||||||
using File = T120B165_ImgBoard.Models.File;
|
using File = T120B165_ImgBoard.Models.File;
|
||||||
|
|
||||||
namespace T120B165_ImgBoard.Dtos.Post;
|
namespace T120B165_ImgBoard.Dtos.Post;
|
||||||
@@ -7,8 +8,9 @@ public record PostDto(
|
|||||||
string Title,
|
string Title,
|
||||||
string Description,
|
string Description,
|
||||||
SlimUserDto Author,
|
SlimUserDto Author,
|
||||||
List<Models.Tag> Tags,
|
List<TagDto> Tags,
|
||||||
string? FileUrl
|
string? FileUrl,
|
||||||
|
DateTime CreatedAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public static PostDto FromPost(Models.Post post, string? fileUrl)
|
public static PostDto FromPost(Models.Post post, string? fileUrl)
|
||||||
@@ -18,8 +20,9 @@ public record PostDto(
|
|||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Description: post.Description,
|
Description: post.Description,
|
||||||
Author: SlimUserDto.FromUser(post.Author),
|
Author: SlimUserDto.FromUser(post.Author),
|
||||||
Tags: post.Tags,
|
Tags: post.Tags.Select(TagDto.FromTag).ToList(),
|
||||||
FileUrl: fileUrl
|
FileUrl: fileUrl,
|
||||||
|
CreatedAt: post.Created
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public record CreateTagDto(
|
|||||||
TagType Type,
|
TagType Type,
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(64)]
|
[StringLength(64)]
|
||||||
|
[RegularExpression(@"^[a-zA-Z0-9-]+$", ErrorMessage = "The name must only contain alphanumeric characters and hyphens.")]
|
||||||
string Name
|
string Name
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
14
T120B165-ImgBoard/Dtos/Tag/TagDto.cs
Normal file
14
T120B165-ImgBoard/Dtos/Tag/TagDto.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using T120B165_ImgBoard.Models;
|
||||||
|
|
||||||
|
namespace T120B165_ImgBoard.Dtos.Tag;
|
||||||
|
|
||||||
|
public record TagDto(string Name, TagType Type)
|
||||||
|
{
|
||||||
|
public static TagDto FromTag(Models.Tag tag)
|
||||||
|
{
|
||||||
|
return new TagDto(
|
||||||
|
Name: tag.Name,
|
||||||
|
Type: tag.Type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,4 +14,6 @@ public class Tag
|
|||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
|
|
||||||
public required TagType Type { get; set; }
|
public required TagType Type { get; set; }
|
||||||
|
|
||||||
|
public List<Post> Posts { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class Program
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddCors();
|
||||||
|
|
||||||
builder.Services.AddIdentity<User, IdentityRole>()
|
builder.Services.AddIdentity<User, IdentityRole>()
|
||||||
.AddEntityFrameworkStores<ImgBoardContext>()
|
.AddEntityFrameworkStores<ImgBoardContext>()
|
||||||
@@ -143,6 +144,13 @@ public class Program
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.UseCors(x => x
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.SetIsOriginAllowed(origin => true) // allow any origin
|
||||||
|
//.WithOrigins("https://localhost:44351")); // Allow only this origin can also have multiple origins separated with comma
|
||||||
|
.AllowCredentials()); // allow credentials
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ public interface IPostService
|
|||||||
string title, string description, List<Tag> tags, User author,
|
string title, string description, List<Tag> tags, User author,
|
||||||
string fileName, string fileContentType, long fileSize);
|
string fileName, string fileContentType, long fileSize);
|
||||||
Task<Post?> GetById(int postId, bool includeUnfinished = false);
|
Task<Post?> GetById(int postId, bool includeUnfinished = false);
|
||||||
Task<PagedList<Post>> GetAll(int pageNumber = 1);
|
|
||||||
public Task<PagedList<Post>> FindAll(string? query, int pageNumber = 1);
|
public Task<PagedList<Post>> FindAll(string? query, int pageNumber = 1);
|
||||||
Task<bool> Delete(Post post);
|
Task<bool> Delete(Post post);
|
||||||
Task<Post> Update(Post post);
|
Task<Post> Update(Post post);
|
||||||
@@ -23,7 +22,7 @@ public record CreatedPost(Post Post, File File);
|
|||||||
|
|
||||||
public class PostService(ImgBoardContext context): IPostService
|
public class PostService(ImgBoardContext context): IPostService
|
||||||
{
|
{
|
||||||
private const int PageSize = 20;
|
private const int PageSize = 8;
|
||||||
public async Task<CreatedPost> Create(
|
public async Task<CreatedPost> Create(
|
||||||
string title,
|
string title,
|
||||||
string description,
|
string description,
|
||||||
@@ -72,20 +71,6 @@ public class PostService(ImgBoardContext context): IPostService
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedList<Post>> GetAll(int pageNumber = 1)
|
|
||||||
{
|
|
||||||
var totalCount = await context.Posts.Where(p => p.File.FinishedDate != null).CountAsync();
|
|
||||||
var items = await context.Posts
|
|
||||||
.Skip((pageNumber - 1) * PageSize)
|
|
||||||
.Take(PageSize)
|
|
||||||
.Where(p => p.File.FinishedDate != null)
|
|
||||||
.Include(b => b.Author)
|
|
||||||
.Include(b => b.Tags)
|
|
||||||
.Include(b => b.File)
|
|
||||||
.ToListAsync();
|
|
||||||
return new PagedList<Post>(items, pageNumber, PageSize, totalCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedList<Post>> FindAll(string? query, int pageNumber = 1)
|
public async Task<PagedList<Post>> FindAll(string? query, int pageNumber = 1)
|
||||||
{
|
{
|
||||||
var postsQuery = context.Posts
|
var postsQuery = context.Posts
|
||||||
|
|||||||
Reference in New Issue
Block a user