Almost finish implementing client side
This commit is contained in:
4
Client/.dockerignore
Normal file
4
Client/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
7
Client/.gitignore
vendored
Normal file
7
Client/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
22
Client/Dockerfile
Normal file
22
Client/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
89
Client/README.md
Normal file
89
Client/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
TODO: automatic refresh based on auth token expiration
|
||||
|
||||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
146
Client/app/api/api.ts
Normal file
146
Client/app/api/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { User } from "~/context/AuthContext";
|
||||
import type { Comment } from "~/types/comment";
|
||||
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||
import type { Post } from "~/types/post";
|
||||
import type { Tag } from "~/types/tag";
|
||||
|
||||
const ENDPOINT = 'http://localhost:5259/api';
|
||||
|
||||
export class API {
|
||||
|
||||
private static async genericRequest(url: string, method: string, body: any = null, headers: Record<string, string> = {}): Promise<Response> {
|
||||
const localHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: localHeaders,
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(url, options);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async get(url: string, headers: Record<string, string> = {}) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
public static async post(url: string, body: any, headers: Record<string, string> = {}): Promise<Response> {
|
||||
return await API.genericRequest(url, 'POST', body, headers);
|
||||
}
|
||||
|
||||
private static async patch(url: string, body: any, headers: Record<string, string> = {}): Promise<Response> {
|
||||
return await API.genericRequest(url, 'PATCH', body, headers);
|
||||
}
|
||||
|
||||
private static async delete(url: string, body: any, headers: Record<string, string> = {}): Promise<Response> {
|
||||
return await API.genericRequest(url, 'DELETE', body, headers);
|
||||
}
|
||||
|
||||
/* Tag APIs */
|
||||
public static async fetchTags(page: number): Promise<PaginatedResponse<Tag>> {
|
||||
const data = await API.get(`${ENDPOINT}/tags?page=${page}`);
|
||||
return data
|
||||
}
|
||||
|
||||
public static async createTag(name: string, tagType: string, user: User): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/tags`, { name, type: tagType }, { Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
public static async updateTag(name: string, newType: string, user: User): Promise<Response> {
|
||||
return await API.patch(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, { type: newType }, { Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
public static async deleteTag(name: string, user: User): Promise<Response> {
|
||||
return await API.delete(`${ENDPOINT}/tags/${encodeURIComponent(name)}`, {}, { Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
|
||||
/* Post APIs */
|
||||
public static async fetchPostById(id: number): Promise<Post|null> {
|
||||
const data = await API.get(`${ENDPOINT}/posts/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
public static async fetchPosts(page: number, filter: string = ""): Promise<PaginatedResponse<Post>> {
|
||||
const data = await API.get(`${ENDPOINT}/posts?page=${page}&query=${encodeURIComponent(filter)}`);
|
||||
return data
|
||||
}
|
||||
|
||||
public static async createPostRequest(
|
||||
title: string, description: string,
|
||||
tags: string[], filename: string,
|
||||
fileMimeType: string, fileSize: number,
|
||||
user: User
|
||||
): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/posts`, {
|
||||
title, description, tags, filename,
|
||||
fileMimeType, fileSize
|
||||
}, { Authorization: `Bearer ${user.accessToken}`})
|
||||
}
|
||||
|
||||
public static async patchFileUploadUrl(uploadUrl: string, image: File, user: User): Promise<Response> {
|
||||
return await fetch(uploadUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Range': `bytes 0-${image.size - 1}/${image.size}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Authorization': `Bearer ${user.accessToken}`,
|
||||
},
|
||||
body: image,
|
||||
});
|
||||
}
|
||||
|
||||
public static async updatePost(postId: number, title: string, description: string, tags: string[], user: User): Promise<Response> {
|
||||
return await API.patch(`${ENDPOINT}/posts/${postId}`, { title, description, tags }, {Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
public static async deletePost(postId: number, user: User): Promise<Response> {
|
||||
return await API.delete(`${ENDPOINT}/posts/${postId}`, {}, {Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
/* Comment APIs */
|
||||
public static async fetchCommentsByPostId(postId: number, page: number): Promise<PaginatedResponse<Comment>> {
|
||||
const data = await API.get(`${ENDPOINT}/posts/${postId}/comments?page=${page}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
public static async postComment(postId: number, text: string, user: User): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/posts/${postId}/comments`, { text }, {Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
public static async updateComment(postId: number, commentId: number, newText: string, user: User): Promise<Response> {
|
||||
return await API.patch(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, { text: newText }, {Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
public static async deleteComment(postId: number, commentId: number, user: User): Promise<Response> {
|
||||
return await API.delete(`${ENDPOINT}/posts/${postId}/comments/${commentId}`, {}, {Authorization: `Bearer ${user.accessToken}`});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Auth APIs */
|
||||
public static async register(username: string, email: string, password: string): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/auth/register`, { username, email, password });
|
||||
}
|
||||
|
||||
public static async login(email: string, password: string): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/auth/login`, { email, password });
|
||||
}
|
||||
|
||||
public static async renewToken(refreshToken: string): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/auth/renew`, { refreshToken });
|
||||
}
|
||||
|
||||
public static async revokeToken(refreshToken: string): Promise<Response> {
|
||||
return await API.post(`${ENDPOINT}/auth/revoke`, { refreshToken });
|
||||
}
|
||||
}
|
||||
|
||||
15
Client/app/app.css
Normal file
15
Client/app/app.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-950;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
9
Client/app/components/Footer.tsx
Normal file
9
Client/app/components/Footer.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-white mt-auto">
|
||||
<div className="container mx-auto py-4 px-4 text-center text-gray-500">
|
||||
© {new Date().getFullYear()} ImageBoard. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
304
Client/app/components/Header.tsx
Normal file
304
Client/app/components/Header.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, type FormEventHandler } from "react";
|
||||
import Icon, { ICONS } from "./Icon";
|
||||
import Modal from "./Modal";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { API } from "~/api/api";
|
||||
import { useAuth } from "~/context/AuthContext";
|
||||
|
||||
export default function Header() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const { user, login, logout, isLoading } = useAuth();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onBack = () => { navigate("/") };
|
||||
const onUploadClick= () => setIsUploadModalOpen(true)
|
||||
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
|
||||
const onTagsClick = () => { navigate("/tags") }
|
||||
const onLoginClick = () => { setIsLoginModalOpen(true); }
|
||||
const onRegisterClick = () => { setIsRegisterModalOpen(true); }
|
||||
|
||||
const onLogout = () => { logout(); if(isMobileMenuOpen) setIsMobileMenuOpen(false); }
|
||||
|
||||
type NavButtonProps = {
|
||||
onClick: () => void;
|
||||
icon: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const NavButton = ({ onClick, icon, children, className = '' }: NavButtonProps) => (
|
||||
<button onClick={onClick} className={`flex items-center space-x-2 px-4 py-2 font-semibold rounded-lg transition-colors ${className}`}>
|
||||
<Icon path={icon} />
|
||||
<span className="hidden md:inline">{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
type MobileNavLinkProps = {
|
||||
onClick: () => void;
|
||||
icon: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MobileNavLink = ({ onClick, icon, children, className = '' }: MobileNavLinkProps) => (
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); onClick(); setIsMobileMenuOpen(false); }} className={`flex items-center space-x-3 px-3 py-2 text-base font-medium rounded-md ${className}`}>
|
||||
<Icon path={icon} />
|
||||
<span>{children}</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
const UploadModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit: FormEventHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!user) return;
|
||||
|
||||
const enteredTags = tags.split(' ').filter(t => t);
|
||||
|
||||
if (!title || !imageFile || !description) {
|
||||
setError('Please fill in all fields and select an image.');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await API.createPostRequest(
|
||||
title, description,
|
||||
enteredTags, imageFile.name,
|
||||
imageFile.type, imageFile.size,
|
||||
user
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(data.detail || `Upload failed with status ${response.status}.`);
|
||||
return;
|
||||
}
|
||||
const uploadUrl = data.fileUrl;
|
||||
if (!uploadUrl) {
|
||||
setError('Upload URL not provided by server.');
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadResponse = await API.patchFileUploadUrl(uploadUrl, imageFile, user);
|
||||
if (!uploadResponse.ok) {
|
||||
setError(`Image upload failed with status ${uploadResponse.status}.`);
|
||||
return;
|
||||
}
|
||||
navigate(`/posts/${data.id}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<h2 className="text-2xl font-bold mb-4">Upload a Post</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-title">Title</label>
|
||||
<input id="post-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="e.g., My Awesome Picture"/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-description">Description</label>
|
||||
<textarea id="post-description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="A short description of your image..."/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-tags">Tags (space-separated)</label>
|
||||
<input id="post-tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="e.g., high-res"/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-image">Image</label>
|
||||
<input id="post-image" type="file" accept="image/*" onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) setImageFile(files[0])
|
||||
}} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||
<div className="flex items-center justify-end">
|
||||
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:bg-blue-300" disabled={!title || !imageFile || !description}>Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const RegisterModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit: FormEventHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await API.register(username, email, password);
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.detail || "Registration failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Automatically log in the user after successful registration
|
||||
const loginResponse = await API.login(email, password);
|
||||
if (loginResponse.ok) {
|
||||
const loginData = await loginResponse.json();
|
||||
login(loginData.accessToken, loginData.refreshToken);
|
||||
}
|
||||
|
||||
onClose();
|
||||
setUsername('');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<h2 className="text-2xl font-bold mb-4">Register</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-username">Username</label>
|
||||
<input id="register-username" type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter username" required/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-email">Email</label>
|
||||
<input id="register-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter email" required/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-password">Password</label>
|
||||
<input id="register-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter password" required/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirm-password">Confirm Password</label>
|
||||
<input id="confirm-password" type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" placeholder="Confirm password" required/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Register</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit: FormEventHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
const response = await API.login(username, password);
|
||||
if (!response.ok) {
|
||||
setError("Login failed. Please check your credentials.");
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
login(data.accessToken, data.refreshToken);
|
||||
onClose();
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isLoginModalOpen} onClose={onClose}>
|
||||
<h2 className="text-2xl font-bold mb-4">Login</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">Email</label>
|
||||
<input id="username" type="email" value={username} onChange={(e) => setUsername(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter email"/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">Password</label>
|
||||
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter password"/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Sign In</button>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); onClose(); onRegisterClick(); }} className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800">
|
||||
Create an Account
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="bg-white shadow-md sticky top-0 z-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800 cursor-pointer" onClick={onBack}>ImgBoard</h1>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<div className="hidden md:flex items-center space-x-2">
|
||||
<NavButton onClick={onTagsClick} icon={ICONS.tags} className="text-gray-600 hover:bg-gray-100">Tags</NavButton>
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-2 font-semibold">Loading...</div>
|
||||
) : user ? (
|
||||
<>
|
||||
<NavButton onClick={onUploadClick} icon={ICONS.upload} className="text-gray-600 hover:bg-gray-100">Upload</NavButton>
|
||||
<NavButton onClick={onLogout} icon={ICONS.logout} className="text-gray-600 hover:bg-gray-100">Logout</NavButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavButton onClick={onLoginClick} icon={ICONS.login} className="bg-green-500 text-white hover:bg-green-600">Login</NavButton>
|
||||
<NavButton onClick={onRegisterClick} icon={ICONS.register} className="bg-blue-500 text-white hover:bg-blue-600">Register</NavButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="md:hidden">
|
||||
<button onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} className="p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100">
|
||||
<Icon path={isMobileMenuOpen ? ICONS.close : ICONS.menu} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden absolute top-16 left-0 right-0 bg-white shadow-lg p-2 z-20">
|
||||
<div className="space-y-1">
|
||||
<MobileNavLink onClick={onTagsClick} icon={ICONS.tags} className="text-gray-600 hover:bg-gray-100">Tags</MobileNavLink>
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2">Loading...</div>
|
||||
) : user ? (
|
||||
<>
|
||||
<MobileNavLink onClick={onUploadClick} icon={ICONS.upload} className="text-gray-700 hover:bg-gray-100">Upload Image</MobileNavLink>
|
||||
<MobileNavLink onClick={onLogout} icon={ICONS.logout} className="text-red-600 hover:bg-red-50">Logout</MobileNavLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MobileNavLink onClick={onLoginClick} icon={ICONS.login} className="text-green-600 hover:bg-green-50">Login</MobileNavLink>
|
||||
<MobileNavLink onClick={onRegisterClick} icon={ICONS.register} className="text-blue-600 hover:bg-blue-50">Register</MobileNavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<LoginModal isOpen={isLoginModalOpen} onClose={() => setIsLoginModalOpen(false)}/>
|
||||
<RegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)}/>
|
||||
<UploadModal isOpen={isUploadModalOpen} onClose={() => setIsUploadModalOpen(false)}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
Client/app/components/Icon.tsx
Normal file
29
Client/app/components/Icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export const ICONS = {
|
||||
back: "M15 19l-7-7 7-7",
|
||||
close: "M6 18L18 6M6 6l12 12",
|
||||
menu: "M4 6h16M4 12h16M4 18h16",
|
||||
search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||
edit: "M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L15.232 5.232z",
|
||||
delete: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16",
|
||||
viewRaw: "M15 12a3 3 0 11-6 0 3 3 0 016 0zM2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
||||
admin: "M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.186 0-6.149 2.08-7.938 5.124z",
|
||||
upload: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12",
|
||||
add: "M12 6v12m6-6H6",
|
||||
login: "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75",
|
||||
logout: "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9",
|
||||
tags: "M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3zM11.25 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3z",
|
||||
register: "M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
};
|
||||
|
||||
type IconProps = {
|
||||
path: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ path, className = "h-6 w-6" }: IconProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
5
Client/app/components/Loading.tsx
Normal file
5
Client/app/components/Loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function Loading() {
|
||||
return <div className="flex justify-center items-center h-64">
|
||||
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-16 w-16"></div>
|
||||
</div>;
|
||||
}
|
||||
21
Client/app/components/Modal.tsx
Normal file
21
Client/app/components/Modal.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Icon, { ICONS } from "./Icon";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, children }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6 relative">
|
||||
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600">
|
||||
<Icon path={ICONS.close} />
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
Client/app/components/NotFound.tsx
Normal file
9
Client/app/components/NotFound.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
|
||||
<h1 className="text-6xl font-bold text-gray-800 mb-4">404</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">The page you are looking for does not exist.</p>
|
||||
<a href="/" className="text-blue-500 hover:underline">Go back to Home</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
Client/app/context/AuthContext.tsx
Normal file
90
Client/app/context/AuthContext.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
login: (accessToken: string, refreshToken: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("user");
|
||||
if (stored) {
|
||||
const storedUser: User = JSON.parse(stored);
|
||||
try {
|
||||
const decoded: { exp: number } = jwtDecode(storedUser.accessToken);
|
||||
if (decoded.exp * 1000 < Date.now()) {
|
||||
// Token is expired, clear user data
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
setUser(null);
|
||||
} else {
|
||||
setUser(storedUser);
|
||||
}
|
||||
} catch (error) {
|
||||
// If token is invalid, clear user data
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = (accessToken: string, refreshToken: string) => {
|
||||
const decoded: any = jwtDecode(accessToken);
|
||||
const roleField = decoded["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
|
||||
const isAdmin = Array.isArray(roleField) ? roleField.includes("Admin") : roleField === "Admin";
|
||||
const newUser: User = {
|
||||
id: decoded.sub,
|
||||
email: decoded.email,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAdmin,
|
||||
};
|
||||
setUser(newUser);
|
||||
localStorage.setItem("user", JSON.stringify(newUser));
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
localStorage.setItem("refreshToken", refreshToken);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
14
Client/app/layout/RootLayout.tsx
Normal file
14
Client/app/layout/RootLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Footer from "~/components/Footer";
|
||||
import Header from "~/components/Header";
|
||||
|
||||
export default function RootLayout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<div className="bg-gray-100 min-h-screen font-sans flex flex-col">
|
||||
<Header/>
|
||||
<main className="container mx-auto pb-8 flex-grow">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
Client/app/root.tsx
Normal file
78
Client/app/root.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <AuthProvider>
|
||||
<Outlet />
|
||||
</AuthProvider>
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details =
|
||||
error.status === 404
|
||||
? "The requested page could not be found."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
8
Client/app/routes.ts
Normal file
8
Client/app/routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
index("routes/posts.tsx"),
|
||||
//route("posts", "routes/posts/posts.tsx"),
|
||||
route("posts/:id", "routes/post.tsx"),
|
||||
route("tags", "routes/tags.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
392
Client/app/routes/post.tsx
Normal file
392
Client/app/routes/post.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import RootLayout from "~/layout/RootLayout";
|
||||
import type { Route } from "../+types/root";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useEffect, useState, type FormEventHandler } from "react";
|
||||
import { API } from "~/api/api";
|
||||
import Icon, { ICONS } from "~/components/Icon";
|
||||
import { getTagColor } from "../utils/tags";
|
||||
|
||||
import type { Post } from "~/types/post";
|
||||
import Modal from "~/components/Modal";
|
||||
import type { Comment } from "~/types/comment";
|
||||
import Loading from "~/components/Loading";
|
||||
import { useAuth } from "~/context/AuthContext";
|
||||
import { NotFound } from "~/components/NotFound";
|
||||
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||
|
||||
type ActionButtonProps = {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
icon: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "ImgBoard - Viewing Post" },
|
||||
];
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export default function Post() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!id) {
|
||||
return <NotFound/>
|
||||
}
|
||||
|
||||
const idNum = parseInt(id, 10);
|
||||
if (isNaN(idNum)) {
|
||||
return <NotFound/>
|
||||
}
|
||||
|
||||
const [post, setPost] = useState<Post|null>(null);
|
||||
const [isEditPostModalOpen, setIsEditPostModalOpen] = useState(false);
|
||||
|
||||
const [paginatedComments, setPaginatedComments] = useState<PaginatedResponse<Comment> | null>(null);
|
||||
const [currentCommentPage, setCurrentCommentPage] = useState(1);
|
||||
const [commentToEdit, setCommentToEdit] = useState<Comment|null>(null);
|
||||
const [isEditCommentModalOpen, setIsEditCommentModalOpen] = useState(false);
|
||||
|
||||
|
||||
console.log("Post ID from params:", id);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch post details using the id
|
||||
|
||||
console.log(`Fetching post with id: ${id}`);
|
||||
|
||||
async function fetchData() {
|
||||
const post = await API.fetchPostById(idNum);
|
||||
setPost(post);
|
||||
}
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchComments() {
|
||||
const comments = await API.fetchCommentsByPostId(idNum, currentCommentPage);
|
||||
setPaginatedComments(comments);
|
||||
}
|
||||
fetchComments();
|
||||
}, [id, currentCommentPage]);
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!post) {
|
||||
return <RootLayout>
|
||||
<Loading />
|
||||
</RootLayout>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Button component for actions like Edit, Delete, View Raw, etc.
|
||||
*/
|
||||
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, icon, children, className }) => (
|
||||
<button onClick={onClick} className={`flex items-center space-x-1.5 font-semibold px-3 py-1.5 rounded-md hover:bg-opacity-80 transition-colors text-sm ${className}`}>
|
||||
<Icon path={icon} className="w-4 h-4" />
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const Pagination = ({
|
||||
itemsPerPage,
|
||||
totalItems,
|
||||
paginate,
|
||||
currentPage,
|
||||
}: {
|
||||
itemsPerPage: number;
|
||||
totalItems: number;
|
||||
paginate: (pageNumber: number) => void;
|
||||
currentPage: number;
|
||||
}) => {
|
||||
const pageNumbers = [];
|
||||
for (let i = 1; i <= Math.ceil(totalItems / itemsPerPage); i++) { pageNumbers.push(i); }
|
||||
if(pageNumbers.length <= 1) return null;
|
||||
return (
|
||||
<nav className="mt-4 flex justify-center">
|
||||
<ul className="flex items-center space-x-2">
|
||||
{pageNumbers.map(number => (
|
||||
<li key={number}>
|
||||
<button onClick={() => paginate(number)} className={`px-3 py-1 rounded-md transition-colors text-sm ${ currentPage === number ? 'bg-blue-500 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
|
||||
{number}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const EditPostModal = () => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (post) {
|
||||
setTitle(post.title);
|
||||
setDescription(post.description);
|
||||
setTags(post.tags.map(t => t.name).join(' '));
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
const handleSubmit: FormEventHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!user) return;
|
||||
const enteredTags = tags.split(' ').filter(t => t);
|
||||
|
||||
if (title && description && tags) {
|
||||
const res = await API.updatePost(post.id, title, description, enteredTags, user);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.detail || "Failed to update post.");
|
||||
return;
|
||||
}
|
||||
setPost(data);
|
||||
setIsEditPostModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!post) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isEditPostModalOpen} onClose={() => setIsEditPostModalOpen(false)}>
|
||||
<h2 className="text-2xl font-bold mb-4">Edit Post</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-title">Title</label>
|
||||
<input id="edit-post-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-description">Description</label>
|
||||
<textarea id="edit-post-description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-tags">Tags</label>
|
||||
<input id="edit-post-tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||
<div className="flex items-center justify-end">
|
||||
<button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const canManagePost = user && (user.isAdmin || user.id === post.author.userId);
|
||||
|
||||
/**
|
||||
* Called when a button to edit a post is clicked.
|
||||
*/
|
||||
const onOpenEditPostModal = () => {
|
||||
setIsEditPostModalOpen(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a button to delete a post is clicked.
|
||||
*/
|
||||
const onPostDelete = async () => {
|
||||
if (!user) return;
|
||||
const res = await API.deletePost(post.id, user)
|
||||
if (!res.ok) {
|
||||
alert("Failed to delete post.");
|
||||
return;
|
||||
}
|
||||
await navigate('/')
|
||||
};
|
||||
|
||||
|
||||
const onAddComment = async (postId: number, commentText: string) => {
|
||||
if (!user || !paginatedComments) return;
|
||||
const res = await API.postComment(postId, commentText, user);
|
||||
if (!res.ok) {
|
||||
alert("Failed to add comment");
|
||||
return;
|
||||
}
|
||||
const newComment: Comment = await res.json();
|
||||
|
||||
const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize);
|
||||
if (currentCommentPage !== totalPages) {
|
||||
setCurrentCommentPage(totalPages);
|
||||
} else {
|
||||
setPaginatedComments(prev => {
|
||||
if (!prev) return null;
|
||||
// If current page is not full, just add the comment
|
||||
if (prev.items.length < prev.pageSize) {
|
||||
return {
|
||||
...prev,
|
||||
items: [...prev.items, newComment],
|
||||
total: prev.totalCount + 1
|
||||
};
|
||||
} else {
|
||||
// This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch.
|
||||
setCurrentCommentPage(totalPages);
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditCommentModal = (comment: Comment) => {
|
||||
setCommentToEdit(comment);
|
||||
setIsEditCommentModalOpen(true);
|
||||
};
|
||||
|
||||
const onDeleteComment = async (postId: number, commentId: number) => {
|
||||
if (!user || !paginatedComments) return;
|
||||
const res = await API.deleteComment(postId, commentId, user);
|
||||
if (!res.ok) {
|
||||
alert("Failed to delete comment.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPaginatedComments(prev => {
|
||||
if (!prev) return null;
|
||||
const newItems = prev.items.filter(c => c.id !== commentId);
|
||||
if (newItems.length === 0 && currentCommentPage > 1) {
|
||||
setCurrentCommentPage(currentCommentPage - 1);
|
||||
}
|
||||
return { ...prev, items: newItems, total: prev.totalCount - 1 };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
//const allTags = [...new Set(posts.flatMap(p => p.tags))];
|
||||
|
||||
const CommentForm = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const handleSubmit: FormEventHandler = async (e) => {
|
||||
setError('');
|
||||
e.preventDefault();
|
||||
if (!commentText.trim()) return;
|
||||
if (!user) return;
|
||||
await onAddComment(post.id, commentText);
|
||||
setCommentText('');
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-6">
|
||||
<textarea className="w-full border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-blue-500 transition-colors" rows={3} placeholder="Write a comment..." value={commentText} onChange={(e) => setCommentText(e.target.value)}></textarea>
|
||||
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
|
||||
<button type="submit" className="mt-2 bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors disabled:bg-blue-300" disabled={!commentText.trim()}>Post Comment</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const EditCommentModal = () => {
|
||||
const [newText, setNewText] = useState(commentToEdit?.text || '');
|
||||
useEffect(() => {
|
||||
if (commentToEdit) {
|
||||
setNewText(commentToEdit.text);
|
||||
}
|
||||
}, [commentToEdit]);
|
||||
const handleSubmit: FormEventHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
if (newText && commentToEdit && newText !== commentToEdit.text && user) {
|
||||
// TODO
|
||||
const res = await API.updateComment(post.id, commentToEdit.id, newText, user);
|
||||
if (!res.ok) {
|
||||
alert("Failed to update comment.");
|
||||
return;
|
||||
}
|
||||
setPaginatedComments(prev => prev ? { ...prev, items: prev.items.map(c => c.id === commentToEdit.id ? {...c, text: newText} : c) } : null);
|
||||
}
|
||||
setIsEditCommentModalOpen(false)
|
||||
};
|
||||
return (
|
||||
<Modal isOpen={isEditCommentModalOpen} onClose={() => setIsEditCommentModalOpen(false)}>
|
||||
<h2 className="text-2xl font-bold mb-4">Edit Comment</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<textarea value={newText} onChange={(e) => setNewText(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" rows={4}/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<RootLayout>
|
||||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<img src={post.fileUrl} alt={post.title} className="w-full h-auto object-cover max-h-[75vh]" />
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-start gap-4">
|
||||
<h2 className="text-3xl font-bold text-gray-800 break-words">{post.title}</h2>
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<a href={post.fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ActionButton icon={ICONS.viewRaw} className="bg-gray-200 text-gray-800">
|
||||
Raw
|
||||
</ActionButton>
|
||||
</a>
|
||||
{canManagePost && (
|
||||
<>
|
||||
<ActionButton onClick={() => onOpenEditPostModal()} icon={ICONS.edit} className="bg-yellow-500 text-white">Edit</ActionButton>
|
||||
<ActionButton onClick={() => onPostDelete()} icon={ICONS.delete} className="bg-red-500 text-white">Delete</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 my-4">
|
||||
{post.tags.map(tag => (
|
||||
<span key={tag.name} className={`text-sm font-medium px-2.5 py-0.5 rounded-full ${getTagColor(tag)}`}>{tag.name}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-md text-gray-600 mb-4">Posted by <span className="font-semibold">{post.author.userName}</span> • {formatDate(post.createdAt)}</p>
|
||||
<p className="text-gray-700 bg-gray-50 p-4 rounded-md whitespace-pre-wrap">{post.description}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-700 border-b pb-2">Comments ({paginatedComments?.totalCount ?? 0})</h3>
|
||||
<div className="space-y-4">
|
||||
{!paginatedComments ? <Loading/> : paginatedComments.items.length > 0 ? (
|
||||
paginatedComments.items.map(comment => {
|
||||
const canManageComment = user && (user.isAdmin || user.id === comment.author.userId);
|
||||
return (
|
||||
<div key={comment.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="font-semibold text-gray-800">{comment.author.userName}</p>
|
||||
{canManageComment && (
|
||||
<div className="space-x-2">
|
||||
<button onClick={() => handleOpenEditCommentModal(comment)} className="text-xs font-semibold text-yellow-600 hover:underline">Edit</button>
|
||||
<button onClick={() => onDeleteComment(post.id, comment.id)} className="text-xs font-semibold text-red-600 hover:underline">Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{comment.text}</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : ( <p className="text-gray-500">No comments yet. Be the first to share your thoughts!</p> )}
|
||||
</div>
|
||||
{paginatedComments && <Pagination itemsPerPage={paginatedComments.pageSize} totalItems={paginatedComments.totalCount} paginate={setCurrentCommentPage} currentPage={currentCommentPage} />}
|
||||
{user && <CommentForm/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditPostModal/>
|
||||
<EditCommentModal/>
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
155
Client/app/routes/posts.tsx
Normal file
155
Client/app/routes/posts.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
|
||||
import type { Route } from "../+types/root";
|
||||
import { useState, useEffect, type FormEventHandler } from "react";
|
||||
|
||||
import RootLayout from "~/layout/RootLayout";
|
||||
import { getTagColor } from "../utils/tags";
|
||||
import type { Post } from "../types/post";
|
||||
|
||||
import Icon, { ICONS } from "../components/Icon";
|
||||
|
||||
import { API } from "../api/api";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { PaginatedResponse } from "~/types/PaginatedResponse";
|
||||
import Loading from "~/components/Loading";
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
onClick: (id: number) => void;
|
||||
}
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "ImgBoard - Posts" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Posts() {
|
||||
|
||||
const PostCard = ({ post, onClick }: PostCardProps) => (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transform hover:-translate-y-1 transition-transform duration-300" onClick={() => onClick(post.id)}>
|
||||
<img src={post.fileUrl} alt={post.title} className="w-full h-48 object-cover" />
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-lg truncate">{post.title}</h3>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{post.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag.name} className={`text-xs font-semibold px-2 py-1 rounded-full ${getTagColor(tag)}`}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 3 && <span className="text-gray-500 text-xs font-semibold py-1">...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
const SearchBar = ({ currentQuery, onSearch }: { currentQuery: string, onSearch: (query: string) => void }) => {
|
||||
const [query, setQuery] = useState(currentQuery);
|
||||
|
||||
const handleSearch: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:px-6 md:pt-6">
|
||||
<form onSubmit={handleSearch} className="max-w-xl mx-auto">
|
||||
<div className="relative">
|
||||
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts by tags (e.g., +high-res -theotown)" className="w-full px-5 py-3 text-gray-700 bg-white border-2 border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"/>
|
||||
<button type="submit" className="absolute top-0 right-0 mt-3 mr-4 text-gray-400 hover:text-gray-600">
|
||||
<Icon path={ICONS.search}/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GridView = ({ posts, onPostSelect }: { posts: Post[]; onPostSelect: (id: number) => void }) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-4 md:p-6">
|
||||
{posts.map(post => <PostCard key={post.id} post={post} onClick={onPostSelect} />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Pagination = ({
|
||||
postsPerPage,
|
||||
totalPosts,
|
||||
paginate,
|
||||
currentPage,
|
||||
}: {
|
||||
postsPerPage: number;
|
||||
totalPosts: number;
|
||||
paginate: (pageNumber: number) => void;
|
||||
currentPage: number;
|
||||
}) => {
|
||||
const pageNumbers = [];
|
||||
for (let i = 1; i <= Math.ceil(totalPosts / postsPerPage); i++) { pageNumbers.push(i); }
|
||||
if(pageNumbers.length <= 1) return null;
|
||||
return (
|
||||
<nav className="mt-8 flex justify-center">
|
||||
<ul className="flex items-center space-x-2">
|
||||
{pageNumbers.map(number => (
|
||||
<li key={number}>
|
||||
<button onClick={() => paginate(number)} className={`px-4 py-2 rounded-md transition-colors ${ currentPage === number ? 'bg-blue-500 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
|
||||
{number}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const [paginatedData, setPaginatedData] = useState<PaginatedResponse<Post> | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const data = await API.fetchPosts(currentPage, searchQuery);
|
||||
setPaginatedData(data);
|
||||
}
|
||||
fetchData();
|
||||
}, [currentPage, searchQuery]);
|
||||
|
||||
const handleSearch = (query: string) => { setSearchQuery(query); setCurrentPage(1); };
|
||||
const handlePostSelect = (id: number) => { navigate(`/posts/${id}`) };
|
||||
|
||||
const currentPosts = paginatedData?.items ?? [];
|
||||
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||
|
||||
if (!paginatedData) {
|
||||
return <RootLayout>
|
||||
<Loading />
|
||||
</RootLayout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RootLayout>
|
||||
{paginatedData.items.length > 0 ?
|
||||
<>
|
||||
<SearchBar currentQuery={searchQuery} onSearch={handleSearch} />
|
||||
<GridView posts={currentPosts} onPostSelect={handlePostSelect} />
|
||||
<Pagination postsPerPage={paginatedData.pageSize} totalPosts={paginatedData.totalCount} paginate={paginate} currentPage={currentPage} />
|
||||
</>
|
||||
:
|
||||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4 text-gray-800">No Posts Found</h2>
|
||||
<p className="text-gray-600 mb-6">Try adjusting your search to find what you're looking for.</p>
|
||||
<button onClick={() => { setSearchQuery(''); setCurrentPage(1); }} className="bg-blue-500 text-white font-semibold px-4 py-2 rounded-md hover:bg-blue-600 transition-colors">
|
||||
Clear Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
7
Client/app/types/PaginatedResponse.tsx
Normal file
7
Client/app/types/PaginatedResponse.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
}
|
||||
7
Client/app/types/comment.tsx
Normal file
7
Client/app/types/comment.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { SlimUser } from "./user"
|
||||
|
||||
export type Comment = {
|
||||
id: number
|
||||
text: string
|
||||
author: SlimUser
|
||||
}
|
||||
12
Client/app/types/post.tsx
Normal file
12
Client/app/types/post.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Tag } from "./tag"
|
||||
import type { SlimUser } from "./user"
|
||||
|
||||
export type Post = {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
author: SlimUser
|
||||
fileUrl: string
|
||||
tags: Tag[]
|
||||
createdAt: string
|
||||
}
|
||||
9
Client/app/types/tag.tsx
Normal file
9
Client/app/types/tag.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum TagType {
|
||||
General = "General",
|
||||
Copyright = "Copyright"
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
name: string
|
||||
type: TagType
|
||||
}
|
||||
4
Client/app/types/user.tsx
Normal file
4
Client/app/types/user.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export type SlimUser = {
|
||||
userId: string
|
||||
userName: string
|
||||
}
|
||||
9
Client/app/utils/tags.tsx
Normal file
9
Client/app/utils/tags.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Tag } from "~/types/tag";
|
||||
|
||||
export const getTagColor = (tag: Tag) => {
|
||||
const categories = {
|
||||
"General": 'bg-green-100 text-green-800',
|
||||
"Copyright": 'bg-yellow-100 text-yellow-800',
|
||||
};
|
||||
return categories[tag.type] || 'bg-gray-200 text-gray-800';
|
||||
}
|
||||
4828
Client/package-lock.json
generated
Normal file
4828
Client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Client/package.json
Normal file
31
Client/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-router/node": "^7.9.2",
|
||||
"@react-router/serve": "^7.9.2",
|
||||
"isbot": "^5.1.31",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.9.2",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
BIN
Client/public/favicon.ico
Normal file
BIN
Client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
Client/react-router.config.ts
Normal file
7
Client/react-router.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
27
Client/tsconfig.json
Normal file
27
Client/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
8
Client/vite.config.ts
Normal file
8
Client/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
});
|
||||
Reference in New Issue
Block a user