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

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