Almost finish implementing client side

This commit is contained in:
2025-11-06 20:22:16 +02:00
parent a847779582
commit a891a686fd
42 changed files with 6450 additions and 57 deletions

View File

@@ -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": { &quot;keyToString&quot;: {
".NET Launch Settings Profile.T120B165-ImgBoard: http.executor": "Run", &quot;.NET Launch Settings Profile.T120B165-ImgBoard: http.executor&quot;: &quot;Run&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"git-widget-placeholder": "main", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"node.js.detected.package.eslint": "true", &quot;git-widget-placeholder&quot;: &quot;main&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"vue.rearranger.settings.migration": "true" &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></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
View File

@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

7
Client/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/

22
Client/Dockerfile Normal file
View 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
View 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.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](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
View 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
View 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;
}
}

View 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">
&copy; {new Date().getFullYear()} ImageBoard. All rights reserved.
</div>
</footer>
)
}

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

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

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

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

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

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

View 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
View 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
View 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
View 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> &bull; {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
View 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>
);
}

View File

@@ -0,0 +1,7 @@
export type PaginatedResponse<T> = {
items: T[];
pageSize: number;
totalPages: number;
totalCount: number;
currentPage: number;
}

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

@@ -0,0 +1,9 @@
export enum TagType {
General = "General",
Copyright = "Copyright"
}
export type Tag = {
name: string
type: TagType
}

View File

@@ -0,0 +1,4 @@
export type SlimUser = {
userId: string
userName: string
}

View 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

File diff suppressed because it is too large Load Diff

31
Client/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

View File

@@ -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.

View File

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

View File

@@ -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));
} }

View File

@@ -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));
} }

View File

@@ -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
); );
} }
} }

View File

@@ -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
); );
} }
} }

View File

@@ -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
); );

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

View File

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

View File

@@ -63,7 +63,8 @@ public class Program
BearerFormat = "jwt" BearerFormat = "jwt"
})); }));
}); });
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();
} }

View File

@@ -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,
@@ -71,20 +70,6 @@ public class PostService(ImgBoardContext context): IPostService
.Include(b => b.File) .Include(b => b.File)
.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)
{ {