init
This commit is contained in:
commit
097d5c4109
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
147
DEMO_SETUP.md
Normal file
147
DEMO_SETUP.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# 🚀 LFG9 Forums - Demo Setup Guide
|
||||||
|
|
||||||
|
**Ready to preview your LFG9 Forums application immediately!**
|
||||||
|
|
||||||
|
## ✅ Current Status
|
||||||
|
|
||||||
|
Your application is **ready to run** with mock data! The database setup has been bypassed with a demo mode that includes:
|
||||||
|
|
||||||
|
- ✨ **Mock Authentication**: Login with any email/password
|
||||||
|
- 🗂️ **Sample Categories**: 9 gaming categories with realistic data
|
||||||
|
- 📝 **Mock Threads**: Sample discussions to showcase the interface
|
||||||
|
- 🎨 **Full Dark Theme**: Complete with your custom color palette
|
||||||
|
- 📱 **Responsive Design**: Mobile-friendly interface
|
||||||
|
|
||||||
|
## 🎯 One-Step Preview
|
||||||
|
|
||||||
|
**Option 1: Frontend Only (Recommended for Quick Preview)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd lfg9-forums/frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Both Frontend and Backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd lfg9-forums
|
||||||
|
npm run setup
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Access Your Application
|
||||||
|
|
||||||
|
- **Frontend**: http://localhost:5173
|
||||||
|
- **Backend**: http://localhost:3000 (if running both)
|
||||||
|
|
||||||
|
## 🎮 Demo Features You Can Test
|
||||||
|
|
||||||
|
### 1. **Authentication System**
|
||||||
|
- Visit http://localhost:5173
|
||||||
|
- Click "Sign Up" or "Login"
|
||||||
|
- Use **any email and password** - it will work!
|
||||||
|
- Experience the dark-themed forms with validation UI
|
||||||
|
|
||||||
|
### 2. **Homepage Experience**
|
||||||
|
- Beautiful gradient hero section
|
||||||
|
- Statistics cards with gaming metrics
|
||||||
|
- Category grid with hover effects
|
||||||
|
- Recent activity sidebar
|
||||||
|
|
||||||
|
### 3. **Categories Page**
|
||||||
|
- Navigate to "Categories" in the header
|
||||||
|
- Browse 9 different gaming categories
|
||||||
|
- Test the search functionality
|
||||||
|
- See hover animations and transitions
|
||||||
|
|
||||||
|
### 4. **Rich Text Editor**
|
||||||
|
- Visit any "Create Thread" page (requires login)
|
||||||
|
- Experience the full TipTap editor
|
||||||
|
- Try formatting tools, headings, lists
|
||||||
|
- Dark theme optimized toolbar
|
||||||
|
|
||||||
|
### 5. **Navigation & UI**
|
||||||
|
- Responsive header with user menu
|
||||||
|
- Search bar (visual only in demo)
|
||||||
|
- Mobile-friendly hamburger menu
|
||||||
|
- Smooth transitions throughout
|
||||||
|
|
||||||
|
## 🎨 Visual Features Implemented
|
||||||
|
|
||||||
|
### **Custom Dark Color Palette**
|
||||||
|
- **Rich Black** (#0C1821): Main backgrounds
|
||||||
|
- **Oxford Blue** (#1B2A41): Cards and secondary areas
|
||||||
|
- **Charcoal** (#324A5F): Text and borders
|
||||||
|
- **Lavender** (#CCC9DC): Accents and highlights
|
||||||
|
|
||||||
|
### **Interactive Elements**
|
||||||
|
- Hover animations on cards and buttons
|
||||||
|
- Focus states with lavender accents
|
||||||
|
- Loading animations and transitions
|
||||||
|
- Responsive grid layouts
|
||||||
|
|
||||||
|
### **Typography & Styling**
|
||||||
|
- Inter font for clean readability
|
||||||
|
- Proper contrast ratios for accessibility
|
||||||
|
- Custom scrollbars matching the theme
|
||||||
|
- Professional spacing and typography
|
||||||
|
|
||||||
|
## 📱 Mobile Testing
|
||||||
|
|
||||||
|
Test the responsive design:
|
||||||
|
- Open browser developer tools
|
||||||
|
- Toggle device view (mobile/tablet)
|
||||||
|
- See how components adapt beautifully
|
||||||
|
|
||||||
|
## 🧪 What's Working in Demo Mode
|
||||||
|
|
||||||
|
✅ **Navigation**: All routing and page transitions
|
||||||
|
✅ **Authentication UI**: Complete login/register flow
|
||||||
|
✅ **Categories**: Full category browsing experience
|
||||||
|
✅ **Rich Text Editor**: All formatting and editing tools
|
||||||
|
✅ **Search Interface**: Search bars and filtering UI
|
||||||
|
✅ **Responsive Design**: Mobile and desktop layouts
|
||||||
|
✅ **Dark Theme**: Complete color system implementation
|
||||||
|
✅ **Animations**: Smooth transitions and hover effects
|
||||||
|
|
||||||
|
## 🔄 Converting to Production
|
||||||
|
|
||||||
|
When ready to connect to real AWS services:
|
||||||
|
|
||||||
|
1. **Update Auth Context**:
|
||||||
|
```typescript
|
||||||
|
// Change in App.tsx
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'; // Real auth
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Backend**:
|
||||||
|
```bash
|
||||||
|
# Set up real AWS credentials in backend/.env
|
||||||
|
# Run database setup: npm run setup-db
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start Both Services**:
|
||||||
|
```bash
|
||||||
|
npm run dev # Runs both frontend and backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Perfect For
|
||||||
|
|
||||||
|
- **Client Demos**: Showcase the complete interface
|
||||||
|
- **Design Review**: Evaluate the dark theme implementation
|
||||||
|
- **User Testing**: Get feedback on UX and navigation
|
||||||
|
- **Development**: Frontend feature development
|
||||||
|
- **Presentation**: Show stakeholders the full experience
|
||||||
|
|
||||||
|
## 💡 Next Steps After Demo
|
||||||
|
|
||||||
|
1. **AWS Setup**: Configure real DynamoDB tables
|
||||||
|
2. **Content Creation**: Add real categories and threads
|
||||||
|
3. **File Uploads**: Test S3 image upload functionality
|
||||||
|
4. **Admin Features**: Implement category management
|
||||||
|
5. **Real-time Features**: Add live updates and notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎮 Your LFG9 Forums application is ready to showcase! Enjoy exploring the beautiful dark-themed gaming forum interface.**
|
||||||
496
DEPLOYMENT.md
Normal file
496
DEPLOYMENT.md
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
Complete deployment guide for the LFG9 Forums application.
|
||||||
|
|
||||||
|
## AWS Infrastructure Setup
|
||||||
|
|
||||||
|
### 1. DynamoDB Tables
|
||||||
|
|
||||||
|
Create the following DynamoDB tables:
|
||||||
|
|
||||||
|
#### Users Table
|
||||||
|
```bash
|
||||||
|
aws dynamodb create-table \
|
||||||
|
--table-name lfg9_forums_users \
|
||||||
|
--attribute-definitions \
|
||||||
|
AttributeName=userId,AttributeType=S \
|
||||||
|
--key-schema \
|
||||||
|
AttributeName=userId,KeyType=HASH \
|
||||||
|
--billing-mode PAY_PER_REQUEST
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Categories Table
|
||||||
|
```bash
|
||||||
|
aws dynamodb create-table \
|
||||||
|
--table-name lfg9_forums_categories \
|
||||||
|
--attribute-definitions \
|
||||||
|
AttributeName=categoryId,AttributeType=S \
|
||||||
|
--key-schema \
|
||||||
|
AttributeName=categoryId,KeyType=HASH \
|
||||||
|
--billing-mode PAY_PER_REQUEST
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Threads Table
|
||||||
|
```bash
|
||||||
|
aws dynamodb create-table \
|
||||||
|
--table-name lfg9_forums_threads \
|
||||||
|
--attribute-definitions \
|
||||||
|
AttributeName=threadId,AttributeType=S \
|
||||||
|
AttributeName=categoryId,AttributeType=S \
|
||||||
|
AttributeName=authorId,AttributeType=S \
|
||||||
|
--key-schema \
|
||||||
|
AttributeName=threadId,KeyType=HASH \
|
||||||
|
--global-secondary-indexes \
|
||||||
|
IndexName=CategoryIdIndex,KeySchema=[{AttributeName=categoryId,KeyType=HASH}],Projection={ProjectionType=ALL} \
|
||||||
|
IndexName=AuthorIdIndex,KeySchema=[{AttributeName=authorId,KeyType=HASH}],Projection={ProjectionType=ALL} \
|
||||||
|
--billing-mode PAY_PER_REQUEST
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Posts Table
|
||||||
|
```bash
|
||||||
|
aws dynamodb create-table \
|
||||||
|
--table-name lfg9_forums_posts \
|
||||||
|
--attribute-definitions \
|
||||||
|
AttributeName=postId,AttributeType=S \
|
||||||
|
AttributeName=threadId,AttributeType=S \
|
||||||
|
AttributeName=authorId,AttributeType=S \
|
||||||
|
--key-schema \
|
||||||
|
AttributeName=postId,KeyType=HASH \
|
||||||
|
--global-secondary-indexes \
|
||||||
|
IndexName=ThreadIdIndex,KeySchema=[{AttributeName=threadId,KeyType=HASH}],Projection={ProjectionType=ALL} \
|
||||||
|
IndexName=AuthorIdIndex,KeySchema=[{AttributeName=authorId,KeyType=HASH}],Projection={ProjectionType=ALL} \
|
||||||
|
--billing-mode PAY_PER_REQUEST
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Files Table
|
||||||
|
```bash
|
||||||
|
aws dynamodb create-table \
|
||||||
|
--table-name lfg9_forums_files \
|
||||||
|
--attribute-definitions \
|
||||||
|
AttributeName=fileId,AttributeType=S \
|
||||||
|
AttributeName=userId,AttributeType=S \
|
||||||
|
--key-schema \
|
||||||
|
AttributeName=fileId,KeyType=HASH \
|
||||||
|
--global-secondary-indexes \
|
||||||
|
IndexName=UserIdIndex,KeySchema=[{AttributeName=userId,KeyType=HASH}],Projection={ProjectionType=ALL} \
|
||||||
|
--billing-mode PAY_PER_REQUEST
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. S3 Bucket Setup
|
||||||
|
|
||||||
|
Create S3 bucket for file uploads:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws s3 mb s3://lfg9-forums-uploads --region us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure bucket policy for proper access:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "PublicReadGetObject",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": "arn:aws:s3:::lfg9-forums-uploads/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure CORS for the bucket:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
|
||||||
|
"AllowedOrigins": ["https://yourdomain.com"],
|
||||||
|
"ExposeHeaders": ["ETag"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. IAM Role and Policies
|
||||||
|
|
||||||
|
Create IAM role for the application:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"dynamodb:GetItem",
|
||||||
|
"dynamodb:PutItem",
|
||||||
|
"dynamodb:Query",
|
||||||
|
"dynamodb:Scan",
|
||||||
|
"dynamodb:UpdateItem",
|
||||||
|
"dynamodb:DeleteItem"
|
||||||
|
],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:dynamodb:*:*:table/lfg9_forums_*",
|
||||||
|
"arn:aws:dynamodb:*:*:table/lfg9_forums_*/index/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:PutObjectAcl"
|
||||||
|
],
|
||||||
|
"Resource": "arn:aws:s3:::lfg9-forums-uploads/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:ListBucket"
|
||||||
|
],
|
||||||
|
"Resource": "arn:aws:s3:::lfg9-forums-uploads"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Deployment
|
||||||
|
|
||||||
|
### Option 1: AWS ECS with Fargate
|
||||||
|
|
||||||
|
1. **Create Dockerfile**:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Build and push to ECR**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the application
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker build -t lfg9-forums-backend .
|
||||||
|
|
||||||
|
# Tag and push to ECR
|
||||||
|
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
|
||||||
|
docker tag lfg9-forums-backend:latest 123456789012.dkr.ecr.us-east-1.amazonaws.com/lfg9-forums-backend:latest
|
||||||
|
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/lfg9-forums-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create ECS Task Definition**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"family": "lfg9-forums-backend",
|
||||||
|
"networkMode": "awsvpc",
|
||||||
|
"requiresCompatibilities": ["FARGATE"],
|
||||||
|
"cpu": "512",
|
||||||
|
"memory": "1024",
|
||||||
|
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
|
||||||
|
"taskRoleArn": "arn:aws:iam::123456789012:role/lfg9-forums-task-role",
|
||||||
|
"containerDefinitions": [
|
||||||
|
{
|
||||||
|
"name": "lfg9-forums-backend",
|
||||||
|
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/lfg9-forums-backend:latest",
|
||||||
|
"portMappings": [
|
||||||
|
{
|
||||||
|
"containerPort": 3000,
|
||||||
|
"protocol": "tcp"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"environment": [
|
||||||
|
{
|
||||||
|
"name": "NODE_ENV",
|
||||||
|
"value": "production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PORT",
|
||||||
|
"value": "3000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secrets": [
|
||||||
|
{
|
||||||
|
"name": "JWT_SECRET",
|
||||||
|
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:lfg9-forums/jwt-secret"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"logConfiguration": {
|
||||||
|
"logDriver": "awslogs",
|
||||||
|
"options": {
|
||||||
|
"awslogs-group": "/ecs/lfg9-forums-backend",
|
||||||
|
"awslogs-region": "us-east-1",
|
||||||
|
"awslogs-stream-prefix": "ecs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: AWS Lambda with Serverless Framework
|
||||||
|
|
||||||
|
1. **Install Serverless Framework**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g serverless
|
||||||
|
npm install --save-dev serverless-offline
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create serverless.yml**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: lfg9-forums-backend
|
||||||
|
|
||||||
|
provider:
|
||||||
|
name: aws
|
||||||
|
runtime: nodejs18.x
|
||||||
|
region: us-east-1
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DYNAMODB_TABLE_PREFIX: ${self:service}-${opt:stage, self:provider.stage}-
|
||||||
|
S3_BUCKET_NAME: lfg9-forums-uploads-${opt:stage, self:provider.stage}
|
||||||
|
iamRoleStatements:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- dynamodb:Query
|
||||||
|
- dynamodb:Scan
|
||||||
|
- dynamodb:GetItem
|
||||||
|
- dynamodb:PutItem
|
||||||
|
- dynamodb:UpdateItem
|
||||||
|
- dynamodb:DeleteItem
|
||||||
|
Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:service}-${opt:stage, self:provider.stage}-*"
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- s3:GetObject
|
||||||
|
- s3:PutObject
|
||||||
|
- s3:DeleteObject
|
||||||
|
Resource: "arn:aws:s3:::lfg9-forums-uploads-${opt:stage, self:provider.stage}/*"
|
||||||
|
|
||||||
|
functions:
|
||||||
|
api:
|
||||||
|
handler: dist/lambda.handler
|
||||||
|
events:
|
||||||
|
- http:
|
||||||
|
path: /{proxy+}
|
||||||
|
method: ANY
|
||||||
|
cors: true
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- serverless-offline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Deployment
|
||||||
|
|
||||||
|
### Option 1: AWS S3 + CloudFront
|
||||||
|
|
||||||
|
1. **Build the frontend**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create S3 bucket for hosting**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws s3 mb s3://lfg9-forums-frontend --region us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure bucket for static website hosting**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws s3 website s3://lfg9-forums-frontend --index-document index.html --error-document index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Upload build files**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws s3 sync dist/ s3://lfg9-forums-frontend --delete
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create CloudFront distribution**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"DistributionConfig": {
|
||||||
|
"CallerReference": "lfg9-forums-frontend-2024",
|
||||||
|
"Comment": "LFG9 Forums Frontend Distribution",
|
||||||
|
"DefaultCacheBehavior": {
|
||||||
|
"TargetOriginId": "S3-lfg9-forums-frontend",
|
||||||
|
"ViewerProtocolPolicy": "redirect-to-https",
|
||||||
|
"TrustedSigners": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Quantity": 0
|
||||||
|
},
|
||||||
|
"ForwardedValues": {
|
||||||
|
"QueryString": false,
|
||||||
|
"Cookies": {
|
||||||
|
"Forward": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MinTTL": 0
|
||||||
|
},
|
||||||
|
"Origins": {
|
||||||
|
"Quantity": 1,
|
||||||
|
"Items": [
|
||||||
|
{
|
||||||
|
"Id": "S3-lfg9-forums-frontend",
|
||||||
|
"DomainName": "lfg9-forums-frontend.s3.amazonaws.com",
|
||||||
|
"S3OriginConfig": {
|
||||||
|
"OriginAccessIdentity": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Enabled": true,
|
||||||
|
"PriceClass": "PriceClass_All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Netlify Deployment
|
||||||
|
|
||||||
|
1. **Create netlify.toml**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = "dist"
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[build.environment]
|
||||||
|
VITE_API_URL = "https://api.yourdomain.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Deploy via Netlify CLI**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g netlify-cli
|
||||||
|
netlify login
|
||||||
|
netlify deploy --prod --dir=dist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Production Environment Variables
|
||||||
|
|
||||||
|
#### Backend (.env)
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=your-production-jwt-secret
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
DYNAMODB_TABLE_PREFIX=lfg9_forums_
|
||||||
|
S3_BUCKET_NAME=lfg9-forums-uploads
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
MAX_FILES_PER_USER=100
|
||||||
|
CORS_ORIGIN=https://yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend (.env.production)
|
||||||
|
```env
|
||||||
|
VITE_API_URL=https://api.yourdomain.com
|
||||||
|
VITE_WS_URL=wss://api.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSL/TLS Configuration
|
||||||
|
|
||||||
|
### AWS Certificate Manager
|
||||||
|
|
||||||
|
1. **Request SSL certificate**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws acm request-certificate \
|
||||||
|
--domain-name yourdomain.com \
|
||||||
|
--subject-alternative-names "*.yourdomain.com" \
|
||||||
|
--validation-method DNS
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Validate certificate** through DNS validation
|
||||||
|
|
||||||
|
3. **Update CloudFront and ALB** to use the certificate
|
||||||
|
|
||||||
|
## Monitoring and Logging
|
||||||
|
|
||||||
|
### CloudWatch Setup
|
||||||
|
|
||||||
|
1. **Create log groups**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws logs create-log-group --log-group-name /aws/ecs/lfg9-forums-backend
|
||||||
|
aws logs create-log-group --log-group-name /aws/lambda/lfg9-forums-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up CloudWatch dashboards** for monitoring:
|
||||||
|
- API response times
|
||||||
|
- Error rates
|
||||||
|
- DynamoDB read/write capacity
|
||||||
|
- S3 upload metrics
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Configure health checks for:
|
||||||
|
- Backend API endpoint (`/health`)
|
||||||
|
- Database connectivity
|
||||||
|
- S3 access
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] Enable AWS WAF on CloudFront
|
||||||
|
- [ ] Configure proper CORS origins
|
||||||
|
- [ ] Use HTTPS everywhere
|
||||||
|
- [ ] Enable DynamoDB encryption at rest
|
||||||
|
- [ ] Configure S3 bucket policies correctly
|
||||||
|
- [ ] Use AWS Secrets Manager for sensitive data
|
||||||
|
- [ ] Enable CloudTrail for API logging
|
||||||
|
- [ ] Configure proper IAM roles and policies
|
||||||
|
- [ ] Enable MFA for AWS root account
|
||||||
|
- [ ] Regularly rotate JWT secrets
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
1. **Enable CloudFront caching** for static assets
|
||||||
|
2. **Configure DynamoDB auto-scaling**
|
||||||
|
3. **Use S3 Transfer Acceleration** for file uploads
|
||||||
|
4. **Enable gzip compression** on CloudFront
|
||||||
|
5. **Implement CDN** for global content delivery
|
||||||
|
6. **Monitor and optimize** database queries
|
||||||
|
|
||||||
|
## Backup and Disaster Recovery
|
||||||
|
|
||||||
|
1. **Enable DynamoDB point-in-time recovery**
|
||||||
|
2. **Configure S3 cross-region replication**
|
||||||
|
3. **Regular database backups**
|
||||||
|
4. **Test disaster recovery procedures**
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
1. **Regular security updates**
|
||||||
|
2. **Monitor AWS service limits**
|
||||||
|
3. **Review and optimize costs**
|
||||||
|
4. **Update dependencies regularly**
|
||||||
|
5. **Monitor application performance**
|
||||||
145
QUICK_START.md
Normal file
145
QUICK_START.md
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# 🚀 Quick Start Guide
|
||||||
|
|
||||||
|
Get your LFG9 Forums testing environment running in minutes!
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+ installed
|
||||||
|
- AWS credentials configured (already done ✅)
|
||||||
|
- Internet connection
|
||||||
|
|
||||||
|
## Quick Setup (5 minutes)
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to project root
|
||||||
|
cd lfg9-forums
|
||||||
|
|
||||||
|
# Install all dependencies for both frontend and backend
|
||||||
|
npm run setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Up Database Tables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create DynamoDB tables in AWS
|
||||||
|
npm run setup-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Development Servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start both frontend and backend servers
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start:
|
||||||
|
- **Backend API**: http://localhost:3000
|
||||||
|
- **Frontend App**: http://localhost:5173
|
||||||
|
|
||||||
|
## 🎯 What You'll See
|
||||||
|
|
||||||
|
1. **Homepage**: Beautiful dark-themed landing page
|
||||||
|
2. **Authentication**: Login/Register with the dark UI
|
||||||
|
3. **Categories**: Forum categories listing
|
||||||
|
4. **Rich Text Editor**: TipTap editor with all formatting options
|
||||||
|
|
||||||
|
## 🔧 Manual Setup (Alternative)
|
||||||
|
|
||||||
|
If the quick setup doesn't work, you can start services individually:
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Test the Application
|
||||||
|
|
||||||
|
1. **Visit**: http://localhost:5173
|
||||||
|
2. **Register**: Create a new account
|
||||||
|
3. **Browse**: Check out the categories page
|
||||||
|
4. **Experience**: The dark theme and rich text editor
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
|
### DynamoDB Connection Issues
|
||||||
|
|
||||||
|
If tables don't create automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify AWS credentials
|
||||||
|
aws sts get-caller-identity
|
||||||
|
|
||||||
|
# Manually create tables
|
||||||
|
node setup-dev-tables.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Conflicts
|
||||||
|
|
||||||
|
If ports 3000 or 5173 are in use:
|
||||||
|
|
||||||
|
**Backend (.env)**:
|
||||||
|
```
|
||||||
|
PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend**: Vite will auto-detect and suggest alternative ports
|
||||||
|
|
||||||
|
### CORS Issues
|
||||||
|
|
||||||
|
Make sure the CORS origin in `backend/.env` matches your frontend URL:
|
||||||
|
```
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Features to Test
|
||||||
|
|
||||||
|
- ✅ **Dark Theme**: Custom color palette throughout
|
||||||
|
- ✅ **Authentication**: Register/Login system
|
||||||
|
- ✅ **Responsive Design**: Test on mobile/tablet
|
||||||
|
- ✅ **Rich Text Editor**: Try all formatting options
|
||||||
|
- ✅ **Navigation**: Header menu and routing
|
||||||
|
- ✅ **Error Handling**: See validation messages
|
||||||
|
|
||||||
|
## 🔄 Development Workflow
|
||||||
|
|
||||||
|
1. **Backend changes**: Auto-restart with nodemon
|
||||||
|
2. **Frontend changes**: Hot module reload with Vite
|
||||||
|
3. **Database**: Tables persist in AWS DynamoDB
|
||||||
|
4. **Files**: S3 bucket for file uploads
|
||||||
|
|
||||||
|
## 📁 Important URLs
|
||||||
|
|
||||||
|
- **Frontend**: http://localhost:5173
|
||||||
|
- **Backend API**: http://localhost:3000
|
||||||
|
- **Health Check**: http://localhost:3000/health
|
||||||
|
- **AWS Console**: https://console.aws.amazon.com/dynamodb/
|
||||||
|
|
||||||
|
## ⚡ Next Steps
|
||||||
|
|
||||||
|
1. **Create Categories**: Add forum categories (admin required)
|
||||||
|
2. **Post Content**: Test the rich text editor
|
||||||
|
3. **Upload Images**: Try file uploads to S3
|
||||||
|
4. **Mobile Testing**: Check responsive design
|
||||||
|
5. **Customize**: Modify colors or add features
|
||||||
|
|
||||||
|
## 🆘 Need Help?
|
||||||
|
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify AWS credentials and permissions
|
||||||
|
- Ensure all dependencies installed correctly
|
||||||
|
- Look at the server logs for API issues
|
||||||
|
|
||||||
|
**Enjoy testing your LFG9 Forums application! 🎮✨**
|
||||||
82
README.md
Normal file
82
README.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# LFG9 Forums
|
||||||
|
|
||||||
|
A modern, full-stack forum application built with React, TypeScript, Node.js, and AWS services.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Rich Text Editor**: Full-featured text editor with image upload, formatting, and mentions
|
||||||
|
- **Dark Theme Design**: Custom color palette optimized for readability
|
||||||
|
- **Real-time Discussions**: Threaded conversations with nested replies
|
||||||
|
- **File Management**: Image upload and storage with AWS S3
|
||||||
|
- **User Authentication**: JWT-based secure authentication
|
||||||
|
- **Responsive Design**: Mobile-friendly interface with Tailwind CSS
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
- Primary Black: `#000000`
|
||||||
|
- Rich Black: `#0C1821` (backgrounds, cards)
|
||||||
|
- Oxford Blue: `#1B2A41` (secondary backgrounds, borders)
|
||||||
|
- Charcoal: `#324A5F` (text, inactive elements)
|
||||||
|
- Lavender: `#CCC9DC` (accent, highlights, active states)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lfg9-forums/
|
||||||
|
├── frontend/ # React TypeScript frontend
|
||||||
|
├── backend/ # Node.js Express backend
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create `.env` files in both frontend and backend directories with the required configurations.
|
||||||
|
|
||||||
|
See individual README files in each directory for detailed setup instructions.
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- React 18+ with TypeScript
|
||||||
|
- React Router v6
|
||||||
|
- Tailwind CSS
|
||||||
|
- TipTap Rich Text Editor
|
||||||
|
- AWS SDK for file uploads
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Node.js with Express
|
||||||
|
- TypeScript
|
||||||
|
- AWS DynamoDB
|
||||||
|
- AWS S3
|
||||||
|
- JWT Authentication
|
||||||
|
- bcrypt for password hashing
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
30
backend/.env
Normal file
30
backend/.env
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=ba666fe9749a19060f1d6dcf1c37f56a5cd2ca0e9207ff51efa457fd80b8333295b2610c7a440e75b202be49cd8b238281b1817fdc830877846384cd9af315ce
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# AWS Configuration
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=AKIAY2ZG3TZB7LZNBMNE
|
||||||
|
AWS_SECRET_ACCESS_KEY=H7MOsaTi6fylIQMyqLQ1Fl+xjcT1NDEY2IY8D+i4
|
||||||
|
|
||||||
|
# DynamoDB Configuration
|
||||||
|
DYNAMODB_TABLE_PREFIX=lfg9_forums_dev_
|
||||||
|
|
||||||
|
# S3 Configuration
|
||||||
|
S3_BUCKET_NAME=lfg9-forums-uploads
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# File Upload Limits
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
MAX_FILES_PER_USER=100
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=http://localhost:5174
|
||||||
264
backend/README.md
Normal file
264
backend/README.md
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
# LFG9 Forums - Backend
|
||||||
|
|
||||||
|
A robust Node.js backend for the LFG9 Forums application with TypeScript, Express, AWS DynamoDB, and S3 integration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **RESTful API**: Express.js with TypeScript
|
||||||
|
- **Database**: AWS DynamoDB with efficient queries and indexes
|
||||||
|
- **File Storage**: AWS S3 with image optimization and multiple sizes
|
||||||
|
- **Authentication**: JWT-based secure authentication
|
||||||
|
- **Rich Content**: JSON-based rich text content storage
|
||||||
|
- **Security**: Input validation, rate limiting, and content sanitization
|
||||||
|
- **Scalable**: Cloud-ready architecture with AWS services
|
||||||
|
|
||||||
|
## Technologies
|
||||||
|
|
||||||
|
- Node.js with Express.js
|
||||||
|
- TypeScript for type safety
|
||||||
|
- AWS DynamoDB for data storage
|
||||||
|
- AWS S3 for file storage
|
||||||
|
- JWT for authentication
|
||||||
|
- bcrypt for password hashing
|
||||||
|
- Sharp for image processing
|
||||||
|
- Joi for input validation
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- AWS Account with DynamoDB and S3 access
|
||||||
|
- AWS CLI configured or environment variables set
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the backend directory:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-here
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# AWS Configuration
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=your-aws-access-key-id
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key
|
||||||
|
|
||||||
|
# DynamoDB Configuration
|
||||||
|
DYNAMODB_TABLE_PREFIX=lfg9_forums_
|
||||||
|
|
||||||
|
# S3 Configuration
|
||||||
|
S3_BUCKET_NAME=lfg9-forums-uploads
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# File Upload Limits
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
MAX_FILES_PER_USER=100
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### DynamoDB Tables
|
||||||
|
|
||||||
|
#### Users Table
|
||||||
|
- **Primary Key**: userId (String)
|
||||||
|
- **Attributes**: username, email, passwordHash, createdAt, updatedAt, profileInfo, storageQuotaUsed, isAdmin
|
||||||
|
|
||||||
|
#### Categories Table
|
||||||
|
- **Primary Key**: categoryId (String)
|
||||||
|
- **Attributes**: name, description, createdAt, updatedAt, threadCount, lastActivity
|
||||||
|
|
||||||
|
#### Threads Table
|
||||||
|
- **Primary Key**: threadId (String)
|
||||||
|
- **GSI**: CategoryIdIndex (categoryId)
|
||||||
|
- **Attributes**: categoryId, title, richContent, authorId, authorUsername, createdAt, updatedAt, attachedFiles, postCount, lastPostAt, isLocked, isPinned
|
||||||
|
|
||||||
|
#### Posts Table
|
||||||
|
- **Primary Key**: postId (String)
|
||||||
|
- **GSI**: ThreadIdIndex (threadId), AuthorIdIndex (authorId)
|
||||||
|
- **Attributes**: threadId, richContent, authorId, authorUsername, createdAt, updatedAt, parentPostId, attachedFiles, isEdited, editedAt
|
||||||
|
|
||||||
|
#### Files Table
|
||||||
|
- **Primary Key**: fileId (String)
|
||||||
|
- **GSI**: UserIdIndex (userId)
|
||||||
|
- **Attributes**: userId, fileName, fileType, fileSize, s3Key, threadId, postId, uploadDate, thumbnailKey, mediumKey, altText
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/register` - User registration
|
||||||
|
- `POST /api/auth/login` - User login
|
||||||
|
- `POST /api/auth/logout` - User logout
|
||||||
|
- `GET /api/auth/me` - Get current user info
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
- `GET /api/categories` - List all categories
|
||||||
|
- `POST /api/categories` - Create new category (admin only)
|
||||||
|
- `GET /api/categories/:id` - Get category by ID
|
||||||
|
- `PUT /api/categories/:id` - Update category (admin only)
|
||||||
|
- `DELETE /api/categories/:id` - Delete category (admin only)
|
||||||
|
|
||||||
|
### Threads
|
||||||
|
- `GET /api/threads` - List threads with filters
|
||||||
|
- `POST /api/threads` - Create new thread
|
||||||
|
- `GET /api/threads/:id` - Get thread by ID
|
||||||
|
- `PUT /api/threads/:id` - Update thread (author/admin only)
|
||||||
|
- `DELETE /api/threads/:id` - Delete thread (author/admin only)
|
||||||
|
- `GET /api/categories/:id/threads` - Get threads in category
|
||||||
|
|
||||||
|
### Posts
|
||||||
|
- `GET /api/threads/:id/posts` - Get posts in thread
|
||||||
|
- `POST /api/threads/:id/posts` - Create new post
|
||||||
|
- `GET /api/posts/:id` - Get post by ID
|
||||||
|
- `PUT /api/posts/:id` - Update post (author/admin only)
|
||||||
|
- `DELETE /api/posts/:id` - Delete post (author/admin only)
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `POST /api/files/upload` - Upload file
|
||||||
|
- `GET /api/files/:id` - Get file metadata
|
||||||
|
- `DELETE /api/files/:id` - Delete file (owner/admin only)
|
||||||
|
- `POST /api/files/presigned-url` - Get presigned upload URL
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- `GET /api/search/threads` - Search threads
|
||||||
|
- `GET /api/search/posts` - Search posts
|
||||||
|
|
||||||
|
## Rich Content Structure
|
||||||
|
|
||||||
|
Rich content is stored as JSON following the TipTap/ProseMirror schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hello world!",
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "bold"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Upload Process
|
||||||
|
|
||||||
|
1. Client requests presigned upload URL
|
||||||
|
2. Server generates presigned URL for S3
|
||||||
|
3. Client uploads file directly to S3
|
||||||
|
4. Server processes and optimizes images (thumbnail, medium, full size)
|
||||||
|
5. File metadata stored in DynamoDB
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- JWT token authentication
|
||||||
|
- Password hashing with bcrypt
|
||||||
|
- Input validation with Joi
|
||||||
|
- Rate limiting per user and IP
|
||||||
|
- Content sanitization for rich text
|
||||||
|
- File type and size validation
|
||||||
|
- Secure S3 bucket configuration
|
||||||
|
- CORS protection
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Centralized error handling with consistent API responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Error message",
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
- `npm run dev` - Start development server with nodemon
|
||||||
|
- `npm run build` - Compile TypeScript
|
||||||
|
- `npm start` - Start production server
|
||||||
|
- `npm run clean` - Remove build files
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── config/ # Configuration files
|
||||||
|
├── controllers/ # Route handlers
|
||||||
|
├── middleware/ # Express middleware
|
||||||
|
├── models/ # Database models
|
||||||
|
├── routes/ # API routes
|
||||||
|
├── services/ # Business logic services
|
||||||
|
├── types/ # TypeScript interfaces
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
└── index.ts # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### AWS Resources Required
|
||||||
|
|
||||||
|
1. **DynamoDB Tables**: Create tables with proper indexes
|
||||||
|
2. **S3 Bucket**: Configure for file uploads with proper permissions
|
||||||
|
3. **IAM Role**: Create role with DynamoDB and S3 permissions
|
||||||
|
4. **EC2/ECS**: For hosting the application
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
1. Set all required environment variables
|
||||||
|
2. Ensure AWS credentials are properly configured
|
||||||
|
3. Create DynamoDB tables with appropriate indexes
|
||||||
|
4. Configure S3 bucket with CORS and permissions
|
||||||
|
|
||||||
|
## Monitoring and Logging
|
||||||
|
|
||||||
|
- Structured logging with Winston (recommended)
|
||||||
|
- Health check endpoint at `/health`
|
||||||
|
- Request logging with Morgan
|
||||||
|
- Error tracking and monitoring
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- DynamoDB query optimization with proper indexes
|
||||||
|
- S3 image optimization and CDN integration
|
||||||
|
- Caching strategies for frequent queries
|
||||||
|
- Connection pooling and resource management
|
||||||
|
- Rate limiting to prevent abuse
|
||||||
4979
backend/package-lock.json
generated
Normal file
4979
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
backend/package.json
Normal file
52
backend/package.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "nodemon src/index.ts",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"prebuild": "npm run clean",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.879.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.879.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.879.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.879.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"compression": "^1.8.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.0.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"joi": "^18.0.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/compression": "^1.8.1",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/morgan": "^1.9.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/config/database.ts
Normal file
28
backend/src/config/database.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
||||||
|
|
||||||
|
const client = new DynamoDBClient({
|
||||||
|
region: process.env.AWS_REGION || 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dynamoDbClient = DynamoDBDocumentClient.from(client);
|
||||||
|
|
||||||
|
export const TableNames = {
|
||||||
|
Users: `${process.env.DYNAMODB_TABLE_PREFIX || 'lfg9_forums_'}users`,
|
||||||
|
Categories: `${process.env.DYNAMODB_TABLE_PREFIX || 'lfg9_forums_'}categories`,
|
||||||
|
Threads: `${process.env.DYNAMODB_TABLE_PREFIX || 'lfg9_forums_'}threads`,
|
||||||
|
Posts: `${process.env.DYNAMODB_TABLE_PREFIX || 'lfg9_forums_'}posts`,
|
||||||
|
Files: `${process.env.DYNAMODB_TABLE_PREFIX || 'lfg9_forums_'}files`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GSINames = {
|
||||||
|
CategoryThreads: 'CategoryIdIndex',
|
||||||
|
ThreadPosts: 'ThreadIdIndex',
|
||||||
|
UserFiles: 'UserIdIndex',
|
||||||
|
UserPosts: 'AuthorIdIndex',
|
||||||
|
UserThreads: 'AuthorIdIndex',
|
||||||
|
};
|
||||||
22
backend/src/config/index.ts
Normal file
22
backend/src/config/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export const config = {
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'your-fallback-secret',
|
||||||
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
|
||||||
|
rateLimit: {
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||||
|
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||||
|
},
|
||||||
|
|
||||||
|
fileUpload: {
|
||||||
|
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB
|
||||||
|
maxFilesPerUser: parseInt(process.env.MAX_FILES_PER_USER || '100', 10),
|
||||||
|
allowedImageTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||||
|
allowedDocumentTypes: ['application/pdf', 'text/plain'],
|
||||||
|
},
|
||||||
|
|
||||||
|
isDevelopment: process.env.NODE_ENV === 'development',
|
||||||
|
isProduction: process.env.NODE_ENV === 'production',
|
||||||
|
} as const;
|
||||||
18
backend/src/config/s3.ts
Normal file
18
backend/src/config/s3.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { S3Client } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
export const s3Client = new S3Client({
|
||||||
|
region: process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME || 'lfg9-forums-uploads';
|
||||||
|
|
||||||
|
export const S3_FOLDERS = {
|
||||||
|
images: 'images/',
|
||||||
|
thumbnails: 'thumbnails/',
|
||||||
|
medium: 'medium/',
|
||||||
|
documents: 'documents/',
|
||||||
|
};
|
||||||
63
backend/src/index.ts
Normal file
63
backend/src/index.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import compression from 'compression';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { config } from './config';
|
||||||
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: config.rateLimit.windowMs,
|
||||||
|
max: config.rateLimit.maxRequests,
|
||||||
|
message: {
|
||||||
|
error: 'Too many requests from this IP, please try again later.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"],
|
||||||
|
fontSrc: ["'self'", "fonts.gstatic.com"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
connectSrc: ["'self'", "https:"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.corsOrigin,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(morgan(config.isDevelopment ? 'dev' : 'combined'));
|
||||||
|
app.use(compression());
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
if (config.isProduction) {
|
||||||
|
app.use(limiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: config.nodeEnv,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`🚀 LFG9 Forums server running on port ${config.port}`);
|
||||||
|
console.log(`📝 Environment: ${config.nodeEnv}`);
|
||||||
|
console.log(`🔗 CORS Origin: ${config.corsOrigin}`);
|
||||||
|
});
|
||||||
159
backend/src/middleware/auth.ts
Normal file
159
backend/src/middleware/auth.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { AuthService } from '../services/authService';
|
||||||
|
import { UserModel } from '../models/User';
|
||||||
|
import { AuthRequest } from '../types';
|
||||||
|
import { AppError } from './errorHandler';
|
||||||
|
|
||||||
|
export const authenticateToken = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const token = AuthService.extractTokenFromHeader(req.headers.authorization);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError('Access token is missing', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = AuthService.verifyToken(token);
|
||||||
|
|
||||||
|
const user = await UserModel.getUserById(payload.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
isAdmin: user.isAdmin || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Token has expired') {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token has expired',
|
||||||
|
code: 'TOKEN_EXPIRED',
|
||||||
|
});
|
||||||
|
} else if (error.message === 'Invalid token') {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid token',
|
||||||
|
code: 'INVALID_TOKEN',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optionalAuth = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const token = AuthService.extractTokenFromHeader(req.headers.authorization);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const payload = AuthService.verifyToken(token);
|
||||||
|
|
||||||
|
const user = await UserModel.getUserById(payload.userId);
|
||||||
|
if (user) {
|
||||||
|
req.user = {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
isAdmin: user.isAdmin || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAdmin = (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Authentication required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.isAdmin) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Admin privileges required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireOwnership = (resourceUserIdField = 'authorId') => {
|
||||||
|
return (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Authentication required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceUserId = (req as any)[resourceUserIdField] || req.body[resourceUserIdField] || req.params[resourceUserIdField];
|
||||||
|
|
||||||
|
if (req.user.isAdmin || req.user.userId === resourceUserId) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'You can only modify your own resources',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rateLimitByUser = (windowMs: number, maxRequests: number) => {
|
||||||
|
const userRequestCounts = new Map<string, { count: number; resetTime: number }>();
|
||||||
|
|
||||||
|
return (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const now = Date.now();
|
||||||
|
const resetTime = now + windowMs;
|
||||||
|
|
||||||
|
const userInfo = userRequestCounts.get(userId);
|
||||||
|
|
||||||
|
if (!userInfo || now > userInfo.resetTime) {
|
||||||
|
userRequestCounts.set(userId, { count: 1, resetTime });
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.count >= maxRequests) {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Too many requests',
|
||||||
|
retryAfter: Math.ceil((userInfo.resetTime - now) / 1000),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo.count++;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
58
backend/src/middleware/errorHandler.ts
Normal file
58
backend/src/middleware/errorHandler.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
isOperational: boolean;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.isOperational = true;
|
||||||
|
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorHandler = (
|
||||||
|
err: Error,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void => {
|
||||||
|
let error = { ...err } as any;
|
||||||
|
error.message = err.message;
|
||||||
|
|
||||||
|
if (config.isDevelopment) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === 'ValidationError') {
|
||||||
|
const message = Object.values(error.errors).map((val: any) => val.message).join(', ');
|
||||||
|
error = new AppError(message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 11000) {
|
||||||
|
const message = 'Duplicate field value entered';
|
||||||
|
error = new AppError(message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
const message = 'Invalid token. Please log in again!';
|
||||||
|
error = new AppError(message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
const message = 'Your token has expired! Please log in again.';
|
||||||
|
error = new AppError(message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(error.statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Server Error',
|
||||||
|
...(config.isDevelopment && { stack: err.stack }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) =>
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
137
backend/src/models/Category.ts
Normal file
137
backend/src/models/Category.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { GetCommand, PutCommand, UpdateCommand, DeleteCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { dynamoDbClient, TableNames } from '../config/database';
|
||||||
|
import { Category, CreateCategoryRequest, UpdateCategoryRequest, PaginationParams } from '../types';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export class CategoryModel {
|
||||||
|
static async createCategory(categoryData: CreateCategoryRequest): Promise<Category> {
|
||||||
|
const categoryId = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const category: Category = {
|
||||||
|
categoryId,
|
||||||
|
name: categoryData.name,
|
||||||
|
description: categoryData.description,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
threadCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = new PutCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Item: category,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCategoryById(categoryId: string): Promise<Category | null> {
|
||||||
|
const command = new GetCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Key: { categoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Item as Category || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listCategories(pagination?: PaginationParams): Promise<{ categories: Category[]; total: number }> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
...(pagination && {
|
||||||
|
Limit: pagination.limit,
|
||||||
|
...(pagination.page > 1 && {
|
||||||
|
ExclusiveStartKey: {
|
||||||
|
categoryId: `page_${pagination.page - 1}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const categories = (result.Items as Category[]) || [];
|
||||||
|
|
||||||
|
const countCommand = new ScanCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countCommand);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
categories.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
|
return { categories, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateCategory(categoryId: string, updates: UpdateCategoryRequest): Promise<Category | null> {
|
||||||
|
const updateExpressions: string[] = [];
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
Object.keys(updates).forEach((key, index) => {
|
||||||
|
if (key !== 'categoryId' && key !== 'createdAt') {
|
||||||
|
const attributeName = `#attr${index}`;
|
||||||
|
const attributeValue = `:val${index}`;
|
||||||
|
|
||||||
|
updateExpressions.push(`${attributeName} = ${attributeValue}`);
|
||||||
|
expressionAttributeNames[attributeName] = key;
|
||||||
|
expressionAttributeValues[attributeValue] = (updates as any)[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExpressions.push('#updatedAt = :updatedAt');
|
||||||
|
expressionAttributeNames['#updatedAt'] = 'updatedAt';
|
||||||
|
expressionAttributeValues[':updatedAt'] = new Date().toISOString();
|
||||||
|
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Key: { categoryId },
|
||||||
|
UpdateExpression: `SET ${updateExpressions.join(', ')}`,
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
ReturnValues: 'ALL_NEW',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Attributes as Category || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteCategory(categoryId: string): Promise<boolean> {
|
||||||
|
const command = new DeleteCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Key: { categoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async incrementThreadCount(categoryId: string): Promise<void> {
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Key: { categoryId },
|
||||||
|
UpdateExpression: 'ADD threadCount :inc SET lastActivity = :now',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':inc': 1,
|
||||||
|
':now': new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decrementThreadCount(categoryId: string): Promise<void> {
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Categories,
|
||||||
|
Key: { categoryId },
|
||||||
|
UpdateExpression: 'ADD threadCount :dec',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':dec': -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
backend/src/models/File.ts
Normal file
185
backend/src/models/File.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { GetCommand, PutCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { dynamoDbClient, TableNames, GSINames } from '../config/database';
|
||||||
|
import { FileMetadata, PaginationParams } from '../types';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export class FileModel {
|
||||||
|
static async createFile(fileData: Omit<FileMetadata, 'fileId' | 'uploadDate'>): Promise<FileMetadata> {
|
||||||
|
const fileId = uuidv4();
|
||||||
|
const uploadDate = new Date().toISOString();
|
||||||
|
|
||||||
|
const file: FileMetadata = {
|
||||||
|
...fileData,
|
||||||
|
fileId,
|
||||||
|
uploadDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = new PutCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
Item: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFileById(fileId: string): Promise<FileMetadata | null> {
|
||||||
|
const command = new GetCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
Key: { fileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Item as FileMetadata || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFilesByUser(
|
||||||
|
userId: string,
|
||||||
|
pagination: PaginationParams
|
||||||
|
): Promise<{ files: FileMetadata[]; total: number }> {
|
||||||
|
const command = new QueryCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
IndexName: GSINames.UserFiles,
|
||||||
|
KeyConditionExpression: 'userId = :userId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':userId': userId,
|
||||||
|
},
|
||||||
|
ScanIndexForward: pagination.sortOrder === 'asc',
|
||||||
|
Limit: pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const files = (result.Items as FileMetadata[]) || [];
|
||||||
|
|
||||||
|
const countCommand = new QueryCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
IndexName: GSINames.UserFiles,
|
||||||
|
KeyConditionExpression: 'userId = :userId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':userId': userId,
|
||||||
|
},
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countCommand);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
return { files, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFilesByThread(threadId: string): Promise<FileMetadata[]> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
FilterExpression: 'threadId = :threadId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':threadId': threadId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return (result.Items as FileMetadata[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFilesByPost(postId: string): Promise<FileMetadata[]> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
FilterExpression: 'postId = :postId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':postId': postId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return (result.Items as FileMetadata[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateFile(fileId: string, updates: Partial<FileMetadata>): Promise<FileMetadata | null> {
|
||||||
|
const updateExpressions: string[] = [];
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
Object.keys(updates).forEach((key, index) => {
|
||||||
|
if (key !== 'fileId' && key !== 'uploadDate') {
|
||||||
|
const attributeName = `#attr${index}`;
|
||||||
|
const attributeValue = `:val${index}`;
|
||||||
|
|
||||||
|
updateExpressions.push(`${attributeName} = ${attributeValue}`);
|
||||||
|
expressionAttributeNames[attributeName] = key;
|
||||||
|
expressionAttributeValues[attributeValue] = (updates as any)[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateExpressions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
Key: { fileId },
|
||||||
|
UpdateExpression: `SET ${updateExpressions.join(', ')}`,
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
ReturnValues: 'ALL_NEW',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Attributes as FileMetadata || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteFile(fileId: string): Promise<boolean> {
|
||||||
|
const command = new DeleteCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
Key: { fileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserStorageUsage(userId: string): Promise<number> {
|
||||||
|
const command = new QueryCommand({
|
||||||
|
TableName: TableNames.Files,
|
||||||
|
IndexName: GSINames.UserFiles,
|
||||||
|
KeyConditionExpression: 'userId = :userId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':userId': userId,
|
||||||
|
},
|
||||||
|
ProjectionExpression: 'fileSize',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const files = (result.Items as Pick<FileMetadata, 'fileSize'>[]) || [];
|
||||||
|
|
||||||
|
return files.reduce((total, file) => total + file.fileSize, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteFilesByUser(userId: string): Promise<boolean> {
|
||||||
|
const files = await this.getFilesByUser(userId, { page: 1, limit: 1000 });
|
||||||
|
|
||||||
|
for (const file of files.files) {
|
||||||
|
await this.deleteFile(file.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteFilesByThread(threadId: string): Promise<boolean> {
|
||||||
|
const files = await this.getFilesByThread(threadId);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await this.deleteFile(file.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteFilesByPost(postId: string): Promise<boolean> {
|
||||||
|
const files = await this.getFilesByPost(postId);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await this.deleteFile(file.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
backend/src/models/Post.ts
Normal file
208
backend/src/models/Post.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { GetCommand, PutCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { dynamoDbClient, TableNames, GSINames } from '../config/database';
|
||||||
|
import { Post, CreatePostRequest, UpdatePostRequest, PaginationParams } from '../types';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export class PostModel {
|
||||||
|
static async createPost(postData: CreatePostRequest, authorId: string, authorUsername: string): Promise<Post> {
|
||||||
|
const postId = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const post: Post = {
|
||||||
|
postId,
|
||||||
|
threadId: postData.threadId,
|
||||||
|
richContent: postData.richContent,
|
||||||
|
authorId,
|
||||||
|
authorUsername,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
parentPostId: postData.parentPostId,
|
||||||
|
attachedFiles: postData.attachedFiles ? [] : undefined,
|
||||||
|
isEdited: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = new PutCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
Item: post,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPostById(postId: string): Promise<Post | null> {
|
||||||
|
const command = new GetCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
Key: { postId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Item as Post || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPostsByThread(
|
||||||
|
threadId: string,
|
||||||
|
pagination: PaginationParams
|
||||||
|
): Promise<{ posts: Post[]; total: number }> {
|
||||||
|
const command = new QueryCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
IndexName: GSINames.ThreadPosts,
|
||||||
|
KeyConditionExpression: 'threadId = :threadId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':threadId': threadId,
|
||||||
|
},
|
||||||
|
ScanIndexForward: pagination.sortOrder === 'asc',
|
||||||
|
Limit: pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const posts = (result.Items as Post[]) || [];
|
||||||
|
|
||||||
|
const countCommand = new QueryCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
IndexName: GSINames.ThreadPosts,
|
||||||
|
KeyConditionExpression: 'threadId = :threadId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':threadId': threadId,
|
||||||
|
},
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countCommand);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
return { posts, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPostsByUser(
|
||||||
|
authorId: string,
|
||||||
|
pagination: PaginationParams
|
||||||
|
): Promise<{ posts: Post[]; total: number }> {
|
||||||
|
const command = new QueryCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
IndexName: GSINames.UserPosts,
|
||||||
|
KeyConditionExpression: 'authorId = :authorId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':authorId': authorId,
|
||||||
|
},
|
||||||
|
ScanIndexForward: pagination.sortOrder === 'asc',
|
||||||
|
Limit: pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const posts = (result.Items as Post[]) || [];
|
||||||
|
|
||||||
|
const countCommand = new QueryCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
IndexName: GSINames.UserPosts,
|
||||||
|
KeyConditionExpression: 'authorId = :authorId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':authorId': authorId,
|
||||||
|
},
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countResult);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
return { posts, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getReplies(parentPostId: string): Promise<Post[]> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
FilterExpression: 'parentPostId = :parentPostId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':parentPostId': parentPostId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const posts = (result.Items as Post[]) || [];
|
||||||
|
|
||||||
|
posts.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updatePost(postId: string, updates: UpdatePostRequest): Promise<Post | null> {
|
||||||
|
const updateExpressions: string[] = [];
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
Object.keys(updates).forEach((key, index) => {
|
||||||
|
if (key !== 'postId' && key !== 'createdAt') {
|
||||||
|
const attributeName = `#attr${index}`;
|
||||||
|
const attributeValue = `:val${index}`;
|
||||||
|
|
||||||
|
updateExpressions.push(`${attributeName} = ${attributeValue}`);
|
||||||
|
expressionAttributeNames[attributeName] = key;
|
||||||
|
expressionAttributeValues[attributeValue] = (updates as any)[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExpressions.push('#updatedAt = :updatedAt', '#isEdited = :isEdited', '#editedAt = :editedAt');
|
||||||
|
expressionAttributeNames['#updatedAt'] = 'updatedAt';
|
||||||
|
expressionAttributeNames['#isEdited'] = 'isEdited';
|
||||||
|
expressionAttributeNames['#editedAt'] = 'editedAt';
|
||||||
|
expressionAttributeValues[':updatedAt'] = new Date().toISOString();
|
||||||
|
expressionAttributeValues[':isEdited'] = true;
|
||||||
|
expressionAttributeValues[':editedAt'] = new Date().toISOString();
|
||||||
|
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
Key: { postId },
|
||||||
|
UpdateExpression: `SET ${updateExpressions.join(', ')}`,
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
ReturnValues: 'ALL_NEW',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Attributes as Post || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deletePost(postId: string): Promise<boolean> {
|
||||||
|
const command = new DeleteCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
Key: { postId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async searchPosts(
|
||||||
|
query: string,
|
||||||
|
threadId?: string,
|
||||||
|
pagination?: PaginationParams
|
||||||
|
): Promise<{ posts: Post[]; total: number }> {
|
||||||
|
const filterExpressions: string[] = [];
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
filterExpressions.push('contains(#content, :query)');
|
||||||
|
expressionAttributeNames['#content'] = 'richContent';
|
||||||
|
expressionAttributeValues[':query'] = query;
|
||||||
|
|
||||||
|
if (threadId) {
|
||||||
|
filterExpressions.push('threadId = :threadId');
|
||||||
|
expressionAttributeValues[':threadId'] = threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Posts,
|
||||||
|
FilterExpression: filterExpressions.join(' AND '),
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
...(pagination && { Limit: pagination.limit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const posts = (result.Items as Post[]) || [];
|
||||||
|
|
||||||
|
posts.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
|
return { posts, total: posts.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
224
backend/src/models/Thread.ts
Normal file
224
backend/src/models/Thread.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { GetCommand, PutCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { dynamoDbClient, TableNames, GSINames } from '../config/database';
|
||||||
|
import { Thread, CreateThreadRequest, UpdateThreadRequest, PaginationParams } from '../types';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export class ThreadModel {
|
||||||
|
static async createThread(threadData: CreateThreadRequest, authorId: string, authorUsername: string): Promise<Thread> {
|
||||||
|
const threadId = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const thread: Thread = {
|
||||||
|
threadId,
|
||||||
|
categoryId: threadData.categoryId,
|
||||||
|
title: threadData.title,
|
||||||
|
richContent: threadData.richContent,
|
||||||
|
authorId,
|
||||||
|
authorUsername,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
attachedFiles: threadData.attachedFiles ? [] : undefined,
|
||||||
|
postCount: 0,
|
||||||
|
lastPostAt: now,
|
||||||
|
isLocked: false,
|
||||||
|
isPinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = new PutCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
Item: thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getThreadById(threadId: string): Promise<Thread | null> {
|
||||||
|
const command = new GetCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
Key: { threadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Item as Thread || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getThreadsByCategory(
|
||||||
|
categoryId: string,
|
||||||
|
pagination: PaginationParams
|
||||||
|
): Promise<{ threads: Thread[]; total: number }> {
|
||||||
|
const command = new QueryCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
IndexName: GSINames.CategoryThreads,
|
||||||
|
KeyConditionExpression: 'categoryId = :categoryId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':categoryId': categoryId,
|
||||||
|
},
|
||||||
|
ScanIndexForward: pagination.sortOrder === 'asc',
|
||||||
|
Limit: pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
let threads = (result.Items as Thread[]) || [];
|
||||||
|
|
||||||
|
threads.sort((a, b) => {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
|
return new Date(b.lastPostAt || b.updatedAt).getTime() - new Date(a.lastPostAt || a.updatedAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const countCommand = new QueryCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
IndexName: GSINames.CategoryThreads,
|
||||||
|
KeyConditionExpression: 'categoryId = :categoryId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':categoryId': categoryId,
|
||||||
|
},
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countCommand);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
return { threads, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getThreadsByUser(
|
||||||
|
authorId: string,
|
||||||
|
pagination: PaginationParams
|
||||||
|
): Promise<{ threads: Thread[]; total: number }> {
|
||||||
|
const command = new QueryCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
IndexName: GSINames.UserThreads,
|
||||||
|
KeyConditionExpression: 'authorId = :authorId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':authorId': authorId,
|
||||||
|
},
|
||||||
|
ScanIndexForward: pagination.sortOrder === 'asc',
|
||||||
|
Limit: pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const threads = (result.Items as Thread[]) || [];
|
||||||
|
|
||||||
|
const countCommand = new QueryCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
IndexName: GSINames.UserThreads,
|
||||||
|
KeyConditionExpression: 'authorId = :authorId',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':authorId': authorId,
|
||||||
|
},
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countCommand);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
return { threads, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateThread(threadId: string, updates: UpdateThreadRequest): Promise<Thread | null> {
|
||||||
|
const updateExpressions: string[] = [];
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
Object.keys(updates).forEach((key, index) => {
|
||||||
|
if (key !== 'threadId' && key !== 'createdAt') {
|
||||||
|
const attributeName = `#attr${index}`;
|
||||||
|
const attributeValue = `:val${index}`;
|
||||||
|
|
||||||
|
updateExpressions.push(`${attributeName} = ${attributeValue}`);
|
||||||
|
expressionAttributeNames[attributeName] = key;
|
||||||
|
expressionAttributeValues[attributeValue] = (updates as any)[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExpressions.push('#updatedAt = :updatedAt');
|
||||||
|
expressionAttributeNames['#updatedAt'] = 'updatedAt';
|
||||||
|
expressionAttributeValues[':updatedAt'] = new Date().toISOString();
|
||||||
|
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
Key: { threadId },
|
||||||
|
UpdateExpression: `SET ${updateExpressions.join(', ')}`,
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
ReturnValues: 'ALL_NEW',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Attributes as Thread || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteThread(threadId: string): Promise<boolean> {
|
||||||
|
const command = new DeleteCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
Key: { threadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async incrementPostCount(threadId: string): Promise<void> {
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
Key: { threadId },
|
||||||
|
UpdateExpression: 'ADD postCount :inc SET lastPostAt = :now',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':inc': 1,
|
||||||
|
':now': new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decrementPostCount(threadId: string): Promise<void> {
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
Key: { threadId },
|
||||||
|
UpdateExpression: 'ADD postCount :dec',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':dec': -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async searchThreads(
|
||||||
|
query: string,
|
||||||
|
categoryId?: string,
|
||||||
|
pagination?: PaginationParams
|
||||||
|
): Promise<{ threads: Thread[]; total: number }> {
|
||||||
|
const filterExpressions: string[] = [];
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
filterExpressions.push('contains(#title, :query) OR contains(#content, :query)');
|
||||||
|
expressionAttributeNames['#title'] = 'title';
|
||||||
|
expressionAttributeNames['#content'] = 'richContent';
|
||||||
|
expressionAttributeValues[':query'] = query;
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
filterExpressions.push('categoryId = :categoryId');
|
||||||
|
expressionAttributeValues[':categoryId'] = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Threads,
|
||||||
|
FilterExpression: filterExpressions.join(' AND '),
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
...(pagination && { Limit: pagination.limit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const threads = (result.Items as Thread[]) || [];
|
||||||
|
|
||||||
|
threads.sort((a, b) => new Date(b.lastPostAt || b.updatedAt).getTime() - new Date(a.lastPostAt || a.updatedAt).getTime());
|
||||||
|
|
||||||
|
return { threads, total: threads.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
163
backend/src/models/User.ts
Normal file
163
backend/src/models/User.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { GetCommand, PutCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { dynamoDbClient, TableNames, GSINames } from '../config/database';
|
||||||
|
import { User, CreateUserRequest, PaginationParams } from '../types';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
export class UserModel {
|
||||||
|
static async createUser(userData: CreateUserRequest): Promise<User> {
|
||||||
|
const userId = uuidv4();
|
||||||
|
const hashedPassword = await bcrypt.hash(userData.password, 12);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
userId,
|
||||||
|
username: userData.username,
|
||||||
|
email: userData.email,
|
||||||
|
passwordHash: hashedPassword,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
storageQuotaUsed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = new PutCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Item: user,
|
||||||
|
ConditionExpression: 'attribute_not_exists(userId) AND attribute_not_exists(email) AND attribute_not_exists(username)',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword as User;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'ConditionalCheckFailedException') {
|
||||||
|
throw new Error('User with this email or username already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserById(userId: string): Promise<User | null> {
|
||||||
|
const command = new GetCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Key: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Item as User || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
FilterExpression: 'email = :email',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':email': email,
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Items?.[0] as User || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserByUsername(username: string): Promise<User | null> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
FilterExpression: 'username = :username',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':username': username,
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Items?.[0] as User || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUser(userId: string, updates: Partial<User>): Promise<User | null> {
|
||||||
|
const updateExpressions: string[] = [];
|
||||||
|
const expressionAttributeNames: Record<string, string> = {};
|
||||||
|
const expressionAttributeValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
Object.keys(updates).forEach((key, index) => {
|
||||||
|
if (key !== 'userId' && key !== 'createdAt') {
|
||||||
|
const attributeName = `#attr${index}`;
|
||||||
|
const attributeValue = `:val${index}`;
|
||||||
|
|
||||||
|
updateExpressions.push(`${attributeName} = ${attributeValue}`);
|
||||||
|
expressionAttributeNames[attributeName] = key;
|
||||||
|
expressionAttributeValues[attributeValue] = (updates as any)[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExpressions.push('#updatedAt = :updatedAt');
|
||||||
|
expressionAttributeNames['#updatedAt'] = 'updatedAt';
|
||||||
|
expressionAttributeValues[':updatedAt'] = new Date().toISOString();
|
||||||
|
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Key: { userId },
|
||||||
|
UpdateExpression: `SET ${updateExpressions.join(', ')}`,
|
||||||
|
ExpressionAttributeNames: expressionAttributeNames,
|
||||||
|
ExpressionAttributeValues: expressionAttributeValues,
|
||||||
|
ReturnValues: 'ALL_NEW',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
return result.Attributes as User || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteUser(userId: string): Promise<boolean> {
|
||||||
|
const command = new DeleteCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Key: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listUsers(pagination: PaginationParams): Promise<{ users: User[]; total: number }> {
|
||||||
|
const command = new ScanCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Limit: pagination.limit,
|
||||||
|
...(pagination.page > 1 && {
|
||||||
|
ExclusiveStartKey: {
|
||||||
|
userId: `page_${pagination.page - 1}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamoDbClient.send(command);
|
||||||
|
const users = (result.Items as User[]) || [];
|
||||||
|
|
||||||
|
const countCommand = new ScanCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Select: 'COUNT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const countResult = await dynamoDbClient.send(countCommand);
|
||||||
|
const total = countResult.Count || 0;
|
||||||
|
|
||||||
|
return { users, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateStorageQuota(userId: string, bytesUsed: number): Promise<void> {
|
||||||
|
const command = new UpdateCommand({
|
||||||
|
TableName: TableNames.Users,
|
||||||
|
Key: { userId },
|
||||||
|
UpdateExpression: 'ADD storageQuotaUsed :bytes',
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':bytes': bytesUsed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dynamoDbClient.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async validatePassword(user: User, password: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, user.passwordHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
backend/src/services/authService.ts
Normal file
101
backend/src/services/authService.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { User } from '../types';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
static generateToken(user: User): string {
|
||||||
|
const payload: JWTPayload = {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isAdmin: user.isAdmin || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(payload, config.jwtSecret, {
|
||||||
|
expiresIn: config.jwtExpiresIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static verifyToken(token: string): JWTPayload {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, config.jwtSecret) as JWTPayload;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
throw new Error('Token has expired');
|
||||||
|
} else if (error.name === 'JsonWebTokenError') {
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static extractTokenFromHeader(authHeader: string | undefined): string | null {
|
||||||
|
if (!authHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateRefreshToken(user: User): string {
|
||||||
|
const payload: JWTPayload = {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isAdmin: user.isAdmin || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(payload, config.jwtSecret, {
|
||||||
|
expiresIn: '30d', // Refresh tokens last 30 days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static decodeTokenWithoutVerification(token: string): JWTPayload | null {
|
||||||
|
try {
|
||||||
|
return jwt.decode(token) as JWTPayload;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static isTokenExpired(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.decode(token) as JWTPayload;
|
||||||
|
if (!decoded || !decoded.exp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
return decoded.exp < currentTime;
|
||||||
|
} catch (error) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getTokenExpirationDate(token: string): Date | null {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.decode(token) as JWTPayload;
|
||||||
|
if (!decoded || !decoded.exp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(decoded.exp * 1000);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
backend/src/services/s3Service.ts
Normal file
187
backend/src/services/s3Service.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
import { s3Client, S3_BUCKET_NAME, S3_FOLDERS } from '../config/s3';
|
||||||
|
import { config } from '../config';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export class S3Service {
|
||||||
|
static async uploadFile(
|
||||||
|
file: Buffer,
|
||||||
|
fileName: string,
|
||||||
|
contentType: string,
|
||||||
|
folder: keyof typeof S3_FOLDERS = 'images'
|
||||||
|
): Promise<{ key: string; url: string }> {
|
||||||
|
const fileExtension = fileName.split('.').pop();
|
||||||
|
const uniqueFileName = `${uuidv4()}.${fileExtension}`;
|
||||||
|
const key = `${S3_FOLDERS[folder]}${uniqueFileName}`;
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: file,
|
||||||
|
ContentType: contentType,
|
||||||
|
CacheControl: 'max-age=31536000', // 1 year
|
||||||
|
Metadata: {
|
||||||
|
originalName: fileName,
|
||||||
|
uploadDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
const url = `https://${S3_BUCKET_NAME}.s3.amazonaws.com/${key}`;
|
||||||
|
return { key, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadImage(
|
||||||
|
file: Buffer,
|
||||||
|
fileName: string,
|
||||||
|
contentType: string
|
||||||
|
): Promise<{
|
||||||
|
originalKey: string;
|
||||||
|
thumbnailKey: string;
|
||||||
|
mediumKey: string;
|
||||||
|
originalUrl: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
mediumUrl: string;
|
||||||
|
}> {
|
||||||
|
const fileExtension = fileName.split('.').pop();
|
||||||
|
const uniqueFileName = `${uuidv4()}.${fileExtension}`;
|
||||||
|
|
||||||
|
const [originalImage, thumbnailImage, mediumImage] = await Promise.all([
|
||||||
|
this.optimizeImage(file, { width: 1920, height: 1080, quality: 85 }),
|
||||||
|
this.optimizeImage(file, { width: 300, height: 200, quality: 80 }),
|
||||||
|
this.optimizeImage(file, { width: 800, height: 600, quality: 85 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [original, thumbnail, medium] = await Promise.all([
|
||||||
|
this.uploadFile(originalImage, uniqueFileName, contentType, 'images'),
|
||||||
|
this.uploadFile(thumbnailImage, uniqueFileName, contentType, 'thumbnails'),
|
||||||
|
this.uploadFile(mediumImage, uniqueFileName, contentType, 'medium'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalKey: original.key,
|
||||||
|
thumbnailKey: thumbnail.key,
|
||||||
|
mediumKey: medium.key,
|
||||||
|
originalUrl: original.url,
|
||||||
|
thumbnailUrl: thumbnail.url,
|
||||||
|
mediumUrl: medium.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteFile(key: string): Promise<void> {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: S3_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteFiles(keys: string[]): Promise<void> {
|
||||||
|
await Promise.all(keys.map(key => this.deleteFile(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPresignedUploadUrl(
|
||||||
|
fileName: string,
|
||||||
|
contentType: string,
|
||||||
|
expiresIn: number = 3600
|
||||||
|
): Promise<{ uploadUrl: string; key: string }> {
|
||||||
|
const fileExtension = fileName.split('.').pop();
|
||||||
|
const uniqueFileName = `${uuidv4()}.${fileExtension}`;
|
||||||
|
const key = `${S3_FOLDERS.images}${uniqueFileName}`;
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
ContentType: contentType,
|
||||||
|
CacheControl: 'max-age=31536000',
|
||||||
|
Metadata: {
|
||||||
|
originalName: fileName,
|
||||||
|
uploadDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn });
|
||||||
|
|
||||||
|
return { uploadUrl, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPresignedDownloadUrl(
|
||||||
|
key: string,
|
||||||
|
expiresIn: number = 3600
|
||||||
|
): Promise<string> {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: S3_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getSignedUrl(s3Client, command, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async optimizeImage(
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
options: { width: number; height: number; quality: number }
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return await sharp(imageBuffer)
|
||||||
|
.resize(options.width, options.height, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.jpeg({
|
||||||
|
quality: options.quality,
|
||||||
|
progressive: true,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFileInfo(key: string): Promise<{
|
||||||
|
exists: boolean;
|
||||||
|
size?: number;
|
||||||
|
lastModified?: Date;
|
||||||
|
contentType?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: S3_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
size: response.ContentLength,
|
||||||
|
lastModified: response.LastModified,
|
||||||
|
contentType: response.ContentType,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'NoSuchKey') {
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateFileType(contentType: string, allowedTypes: string[]): boolean {
|
||||||
|
return allowedTypes.includes(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateFileSize(size: number, maxSize: number): boolean {
|
||||||
|
return size <= maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isImageType(contentType: string): boolean {
|
||||||
|
return config.fileUpload.allowedImageTypes.includes(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isDocumentType(contentType: string): boolean {
|
||||||
|
return config.fileUpload.allowedDocumentTypes.includes(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
static generatePublicUrl(key: string): string {
|
||||||
|
return `https://${S3_BUCKET_NAME}.s3.amazonaws.com/${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
backend/src/types/index.ts
Normal file
177
backend/src/types/index.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
export interface User {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
passwordHash: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
profileInfo?: {
|
||||||
|
bio?: string;
|
||||||
|
avatar?: string;
|
||||||
|
location?: string;
|
||||||
|
};
|
||||||
|
storageQuotaUsed: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
threadCount?: number;
|
||||||
|
lastActivity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
threadId: string;
|
||||||
|
categoryId: string;
|
||||||
|
title: string;
|
||||||
|
richContent: RichContent;
|
||||||
|
authorId: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
attachedFiles?: FileMetadata[];
|
||||||
|
postCount?: number;
|
||||||
|
lastPostAt?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
isPinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
postId: string;
|
||||||
|
threadId: string;
|
||||||
|
richContent: RichContent;
|
||||||
|
authorId: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
parentPostId?: string;
|
||||||
|
attachedFiles?: FileMetadata[];
|
||||||
|
isEdited?: boolean;
|
||||||
|
editedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
fileId: string;
|
||||||
|
userId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
fileSize: number;
|
||||||
|
s3Key: string;
|
||||||
|
threadId?: string;
|
||||||
|
postId?: string;
|
||||||
|
uploadDate: string;
|
||||||
|
thumbnailKey?: string;
|
||||||
|
mediumKey?: string;
|
||||||
|
altText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichContent {
|
||||||
|
type: 'doc';
|
||||||
|
content: RichContentNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichContentNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: RichContentNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: RichContentMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichContentMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchParams {
|
||||||
|
query: string;
|
||||||
|
categoryId?: string;
|
||||||
|
authorId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryRequest {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateThreadRequest {
|
||||||
|
title: string;
|
||||||
|
richContent: RichContent;
|
||||||
|
categoryId: string;
|
||||||
|
attachedFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateThreadRequest {
|
||||||
|
title?: string;
|
||||||
|
richContent?: RichContent;
|
||||||
|
attachedFiles?: string[];
|
||||||
|
isLocked?: boolean;
|
||||||
|
isPinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePostRequest {
|
||||||
|
richContent: RichContent;
|
||||||
|
threadId: string;
|
||||||
|
parentPostId?: string;
|
||||||
|
attachedFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePostRequest {
|
||||||
|
richContent?: RichContent;
|
||||||
|
attachedFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadRequest {
|
||||||
|
file: Express.Multer.File;
|
||||||
|
altText?: string;
|
||||||
|
}
|
||||||
261
backend/src/utils/validation.ts
Normal file
261
backend/src/utils/validation.ts
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import Joi from 'joi';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { RichContent } from '../types';
|
||||||
|
|
||||||
|
export const userRegistrationSchema = Joi.object({
|
||||||
|
username: Joi.string()
|
||||||
|
.alphanum()
|
||||||
|
.min(3)
|
||||||
|
.max(30)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.alphanum': 'Username must contain only alphanumeric characters',
|
||||||
|
'string.min': 'Username must be at least 3 characters long',
|
||||||
|
'string.max': 'Username must be less than 30 characters long',
|
||||||
|
'any.required': 'Username is required',
|
||||||
|
}),
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.email': 'Please enter a valid email address',
|
||||||
|
'any.required': 'Email is required',
|
||||||
|
}),
|
||||||
|
password: Joi.string()
|
||||||
|
.min(8)
|
||||||
|
.pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]'))
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.min': 'Password must be at least 8 characters long',
|
||||||
|
'string.pattern.base': 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
|
||||||
|
'any.required': 'Password is required',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userLoginSchema = Joi.object({
|
||||||
|
email: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.email': 'Please enter a valid email address',
|
||||||
|
'any.required': 'Email is required',
|
||||||
|
}),
|
||||||
|
password: Joi.string()
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'any.required': 'Password is required',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categorySchema = Joi.object({
|
||||||
|
name: Joi.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.min': 'Category name cannot be empty',
|
||||||
|
'string.max': 'Category name must be less than 100 characters',
|
||||||
|
'any.required': 'Category name is required',
|
||||||
|
}),
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.max': 'Category description must be less than 500 characters',
|
||||||
|
'any.required': 'Category description is required',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateCategorySchema = Joi.object({
|
||||||
|
name: Joi.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.messages({
|
||||||
|
'string.min': 'Category name cannot be empty',
|
||||||
|
'string.max': 'Category name must be less than 100 characters',
|
||||||
|
}),
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.messages({
|
||||||
|
'string.max': 'Category description must be less than 500 characters',
|
||||||
|
}),
|
||||||
|
}).min(1);
|
||||||
|
|
||||||
|
const richContentSchema = Joi.object({
|
||||||
|
type: Joi.string().valid('doc').required(),
|
||||||
|
content: Joi.array().items(Joi.object().unknown(true)).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const threadSchema = Joi.object({
|
||||||
|
title: Joi.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.min': 'Thread title cannot be empty',
|
||||||
|
'string.max': 'Thread title must be less than 200 characters',
|
||||||
|
'any.required': 'Thread title is required',
|
||||||
|
}),
|
||||||
|
richContent: richContentSchema.required(),
|
||||||
|
categoryId: Joi.string()
|
||||||
|
.guid({ version: 'uuidv4' })
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.guid': 'Invalid category ID format',
|
||||||
|
'any.required': 'Category ID is required',
|
||||||
|
}),
|
||||||
|
attachedFiles: Joi.array().items(Joi.string().guid({ version: 'uuidv4' })).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateThreadSchema = Joi.object({
|
||||||
|
title: Joi.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.messages({
|
||||||
|
'string.min': 'Thread title cannot be empty',
|
||||||
|
'string.max': 'Thread title must be less than 200 characters',
|
||||||
|
}),
|
||||||
|
richContent: richContentSchema.optional(),
|
||||||
|
attachedFiles: Joi.array().items(Joi.string().guid({ version: 'uuidv4' })).optional(),
|
||||||
|
isLocked: Joi.boolean().optional(),
|
||||||
|
isPinned: Joi.boolean().optional(),
|
||||||
|
}).min(1);
|
||||||
|
|
||||||
|
export const postSchema = Joi.object({
|
||||||
|
richContent: richContentSchema.required(),
|
||||||
|
threadId: Joi.string()
|
||||||
|
.guid({ version: 'uuidv4' })
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.guid': 'Invalid thread ID format',
|
||||||
|
'any.required': 'Thread ID is required',
|
||||||
|
}),
|
||||||
|
parentPostId: Joi.string()
|
||||||
|
.guid({ version: 'uuidv4' })
|
||||||
|
.optional()
|
||||||
|
.messages({
|
||||||
|
'string.guid': 'Invalid parent post ID format',
|
||||||
|
}),
|
||||||
|
attachedFiles: Joi.array().items(Joi.string().guid({ version: 'uuidv4' })).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePostSchema = Joi.object({
|
||||||
|
richContent: richContentSchema.optional(),
|
||||||
|
attachedFiles: Joi.array().items(Joi.string().guid({ version: 'uuidv4' })).optional(),
|
||||||
|
}).min(1);
|
||||||
|
|
||||||
|
export const paginationSchema = Joi.object({
|
||||||
|
page: Joi.number().integer().min(1).default(1),
|
||||||
|
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||||
|
sortBy: Joi.string().optional(),
|
||||||
|
sortOrder: Joi.string().valid('asc', 'desc').default('desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchSchema = Joi.object({
|
||||||
|
query: Joi.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.min': 'Search query cannot be empty',
|
||||||
|
'string.max': 'Search query must be less than 200 characters',
|
||||||
|
'any.required': 'Search query is required',
|
||||||
|
}),
|
||||||
|
categoryId: Joi.string()
|
||||||
|
.guid({ version: 'uuidv4' })
|
||||||
|
.optional()
|
||||||
|
.messages({
|
||||||
|
'string.guid': 'Invalid category ID format',
|
||||||
|
}),
|
||||||
|
authorId: Joi.string()
|
||||||
|
.guid({ version: 'uuidv4' })
|
||||||
|
.optional()
|
||||||
|
.messages({
|
||||||
|
'string.guid': 'Invalid author ID format',
|
||||||
|
}),
|
||||||
|
dateFrom: Joi.date().iso().optional(),
|
||||||
|
dateTo: Joi.date().iso().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const validateSchema = (schema: Joi.ObjectSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const { error, value } = schema.validate(req.body, {
|
||||||
|
abortEarly: false,
|
||||||
|
stripUnknown: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorDetails = error.details.map((detail) => ({
|
||||||
|
field: detail.path.join('.'),
|
||||||
|
message: detail.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: errorDetails,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.body = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateQuery = (schema: Joi.ObjectSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const { error, value } = schema.validate(req.query, {
|
||||||
|
abortEarly: false,
|
||||||
|
stripUnknown: true,
|
||||||
|
convert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorDetails = error.details.map((detail) => ({
|
||||||
|
field: detail.path.join('.'),
|
||||||
|
message: detail.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Query validation failed',
|
||||||
|
details: errorDetails,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.query = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidRichContent = (content: any): content is RichContent => {
|
||||||
|
if (!content || typeof content !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.type !== 'doc' || !Array.isArray(content.content)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeRichContentText = (content: RichContent): string => {
|
||||||
|
const extractText = (nodes: any[]): string => {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
if (node.text) {
|
||||||
|
return node.text;
|
||||||
|
}
|
||||||
|
if (node.content) {
|
||||||
|
return extractText(node.content);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return extractText(content.content).trim();
|
||||||
|
};
|
||||||
34
backend/tsconfig.json
Normal file
34
backend/tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"typeRoots": ["node_modules/@types"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
69
frontend/README.md
Normal file
69
frontend/README.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5901
frontend/package-lock.json
generated
Normal file
5901
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/package.json
Normal file
51
frontend/package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.3.1",
|
||||||
|
"@tiptap/extension-color": "^3.3.1",
|
||||||
|
"@tiptap/extension-highlight": "^3.3.1",
|
||||||
|
"@tiptap/extension-image": "^3.3.1",
|
||||||
|
"@tiptap/extension-link": "^3.3.1",
|
||||||
|
"@tiptap/extension-mention": "^3.3.1",
|
||||||
|
"@tiptap/extension-text-style": "^3.3.1",
|
||||||
|
"@tiptap/pm": "^3.3.1",
|
||||||
|
"@tiptap/react": "^3.3.1",
|
||||||
|
"@tiptap/starter-kit": "^3.3.1",
|
||||||
|
"@types/axios": "^0.9.36",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"aws-sdk": "^2.1692.0",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.8.2",
|
||||||
|
"tailwindcss": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.39.1",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
62
frontend/src/App.tsx
Normal file
62
frontend/src/App.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './contexts/MockAuthContext';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Register from './pages/Register';
|
||||||
|
import Categories from './pages/Categories';
|
||||||
|
import CategoryDetail from './pages/CategoryDetail';
|
||||||
|
import ThreadDetail from './pages/ThreadDetail';
|
||||||
|
import CreateThread from './pages/CreateThread';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black text-lfg-lavender">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/categories" element={<Categories />} />
|
||||||
|
<Route path="/categories/:categoryId" element={<CategoryDetail />} />
|
||||||
|
<Route path="/threads/:threadId" element={<ThreadDetail />} />
|
||||||
|
<Route
|
||||||
|
path="/threads/new"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CreateThread />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile/:userId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Profile />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Settings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
73
frontend/src/components/CategoryCard.tsx
Normal file
73
frontend/src/components/CategoryCard.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { MessageCircle, Clock, Users } from 'lucide-react';
|
||||||
|
import { type Category } from '../types/index';
|
||||||
|
|
||||||
|
interface CategoryCardProps {
|
||||||
|
category: Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryCard: React.FC<CategoryCardProps> = ({ category }) => {
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||||
|
|
||||||
|
if (diffInHours < 1) {
|
||||||
|
return 'Just now';
|
||||||
|
} else if (diffInHours < 24) {
|
||||||
|
return `${diffInHours}h ago`;
|
||||||
|
} else if (diffInHours < 168) {
|
||||||
|
return `${Math.floor(diffInHours / 24)}d ago`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/categories/${category.categoryId}`}
|
||||||
|
className="block group transition-all duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
<div className="bg-lfg-oxford border border-lfg-charcoal rounded-lg p-6 hover:border-lfg-lavender hover:shadow-lg hover:shadow-lfg-lavender/10 transition-all duration-200">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-lfg-lavender group-hover:text-white transition-colors mb-2">
|
||||||
|
{category.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-lfg-charcoal group-hover:text-lfg-lavender transition-colors line-clamp-2">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 p-3 bg-lfg-rich-black rounded-lg">
|
||||||
|
<MessageCircle size={24} className="text-lfg-lavender" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center space-x-4 text-lfg-charcoal">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Users size={16} />
|
||||||
|
<span>{category.threadCount || 0} threads</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{category.lastActivity && (
|
||||||
|
<div className="flex items-center space-x-1 text-lfg-charcoal">
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>{formatDate(category.lastActivity)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<div className="px-3 py-1 bg-lfg-lavender text-lfg-black text-sm rounded-full font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
View Category
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryCard;
|
||||||
158
frontend/src/components/Header.tsx
Normal file
158
frontend/src/components/Header.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Search, User, LogOut, Settings, Bell } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/MockAuthContext';
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-lfg-rich-black border-b border-lfg-oxford sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center space-x-8">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-2xl font-bold text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
LFG9 Forums
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center space-x-6">
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
className="text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Categories
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/recent"
|
||||||
|
className="text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</Link>
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search forums..."
|
||||||
|
className="
|
||||||
|
w-64 px-4 py-2 pl-10 pr-4
|
||||||
|
bg-lfg-oxford border border-lfg-charcoal rounded-lg
|
||||||
|
text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Search
|
||||||
|
size={20}
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-lfg-charcoal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button className="p-2 text-lfg-lavender hover:text-white hover:bg-lfg-oxford rounded-lg transition-all duration-200">
|
||||||
|
<Bell size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative group">
|
||||||
|
<button className="flex items-center space-x-2 p-2 text-lfg-lavender hover:text-white hover:bg-lfg-oxford rounded-lg transition-all duration-200">
|
||||||
|
<User size={20} />
|
||||||
|
<span className="hidden sm:inline">{user.username}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-lfg-oxford border border-lfg-charcoal rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||||
|
<div className="py-2">
|
||||||
|
<Link
|
||||||
|
to={`/profile/${user.userId}`}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-lfg-lavender hover:bg-lfg-charcoal hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<User size={16} className="mr-2" />
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-lfg-lavender hover:bg-lfg-charcoal hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={16} className="mr-2" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-lfg-charcoal my-1"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-lfg-lavender hover:bg-lfg-charcoal hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={16} className="mr-2" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="px-4 py-2 text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="px-4 py-2 bg-lfg-lavender text-lfg-black rounded-lg hover:bg-white transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:hidden border-t border-lfg-oxford">
|
||||||
|
<nav className="px-4 py-3 space-y-2">
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
className="block text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Categories
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/recent"
|
||||||
|
className="block text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</Link>
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="block text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
33
frontend/src/components/ProtectedRoute.tsx
Normal file
33
frontend/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/MockAuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
|
||||||
|
const { isAuthenticated, user, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black flex items-center justify-center">
|
||||||
|
<div className="loading-spinner w-8 h-8"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && !user?.isAdmin) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
285
frontend/src/components/RichTextEditor.tsx
Normal file
285
frontend/src/components/RichTextEditor.tsx
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import TextStyle from '@tiptap/extension-text-style';
|
||||||
|
import Color from '@tiptap/extension-color';
|
||||||
|
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||||
|
import { lowlight } from 'lowlight';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Strikethrough,
|
||||||
|
Code,
|
||||||
|
Quote,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
content?: any;
|
||||||
|
onChange?: (content: any) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
editable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuButton: React.FC<{
|
||||||
|
onClick: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
}> = ({ onClick, isActive, disabled, children, title }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
className={`
|
||||||
|
p-2 rounded-md text-sm font-medium transition-all duration-200
|
||||||
|
${isActive
|
||||||
|
? 'bg-lfg-lavender text-lfg-black'
|
||||||
|
: 'text-lfg-lavender hover:bg-lfg-oxford hover:text-white'
|
||||||
|
}
|
||||||
|
${disabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:scale-105'
|
||||||
|
}
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Start writing...',
|
||||||
|
editable = true,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false,
|
||||||
|
}),
|
||||||
|
Image.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'max-w-full h-auto rounded-lg',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'text-lfg-lavender hover:text-white underline',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Highlight.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'bg-lfg-lavender text-lfg-black px-1 rounded',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'bg-lfg-black text-lfg-lavender p-4 rounded-lg overflow-x-auto',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editable,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange?.(editor.getJSON());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addImage = useCallback(() => {
|
||||||
|
const url = window.prompt('Enter image URL:');
|
||||||
|
if (url && editor) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run();
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const setLink = useCallback(() => {
|
||||||
|
const previousUrl = editor?.getAttributes('link').href;
|
||||||
|
const url = window.prompt('Enter URL:', previousUrl);
|
||||||
|
|
||||||
|
if (url === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === '') {
|
||||||
|
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return <div className="animate-pulse bg-lfg-oxford h-32 rounded-lg"></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border border-lfg-oxford rounded-lg bg-lfg-rich-black ${className}`}>
|
||||||
|
{editable && (
|
||||||
|
<div className="border-b border-lfg-oxford p-2 flex flex-wrap gap-1">
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
isActive={editor.isActive('bold')}
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
isActive={editor.isActive('italic')}
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
isActive={editor.isActive('strike')}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
isActive={editor.isActive('code')}
|
||||||
|
title="Code"
|
||||||
|
>
|
||||||
|
<Code size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<div className="w-px bg-lfg-charcoal mx-1"></div>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 1 })}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 2 })}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 3 })}
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<div className="w-px bg-lfg-charcoal mx-1"></div>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
isActive={editor.isActive('bulletList')}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
isActive={editor.isActive('orderedList')}
|
||||||
|
title="Numbered List"
|
||||||
|
>
|
||||||
|
<ListOrdered size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
isActive={editor.isActive('blockquote')}
|
||||||
|
title="Quote"
|
||||||
|
>
|
||||||
|
<Quote size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<div className="w-px bg-lfg-charcoal mx-1"></div>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={setLink}
|
||||||
|
isActive={editor.isActive('link')}
|
||||||
|
title="Add Link"
|
||||||
|
>
|
||||||
|
<LinkIcon size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={addImage}
|
||||||
|
title="Add Image"
|
||||||
|
>
|
||||||
|
<ImageIcon size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<div className="w-px bg-lfg-charcoal mx-1"></div>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().chain().focus().undo().run()}
|
||||||
|
title="Undo"
|
||||||
|
>
|
||||||
|
<Undo size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuButton
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().chain().focus().redo().run()}
|
||||||
|
title="Redo"
|
||||||
|
>
|
||||||
|
<Redo size={16} />
|
||||||
|
</MenuButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className={`
|
||||||
|
prose prose-invert max-w-none p-4
|
||||||
|
prose-headings:text-lfg-lavender prose-p:text-lfg-lavender
|
||||||
|
prose-strong:text-lfg-lavender prose-code:text-lfg-lavender
|
||||||
|
prose-blockquote:border-lfg-lavender prose-blockquote:text-lfg-charcoal
|
||||||
|
prose-li:text-lfg-lavender prose-a:text-lfg-lavender
|
||||||
|
focus-within:ring-2 focus-within:ring-lfg-lavender focus-within:ring-opacity-50
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<div className="px-4 py-2 text-xs text-lfg-charcoal border-t border-lfg-oxford">
|
||||||
|
<p>
|
||||||
|
Tips: Use <kbd className="bg-lfg-oxford px-1 rounded text-lfg-lavender">**bold**</kbd>,{' '}
|
||||||
|
<kbd className="bg-lfg-oxford px-1 rounded text-lfg-lavender">*italic*</kbd>,{' '}
|
||||||
|
<kbd className="bg-lfg-oxford px-1 rounded text-lfg-lavender">`code`</kbd>,{' '}
|
||||||
|
or paste images directly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RichTextEditor;
|
||||||
107
frontend/src/components/ThreadCard.tsx
Normal file
107
frontend/src/components/ThreadCard.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { MessageCircle, User, Clock, Pin, Lock } from 'lucide-react';
|
||||||
|
import { type Thread } from '../types/index';
|
||||||
|
|
||||||
|
interface ThreadCardProps {
|
||||||
|
thread: Thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThreadCard: React.FC<ThreadCardProps> = ({ thread }) => {
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||||
|
|
||||||
|
if (diffInHours < 1) {
|
||||||
|
return 'Just now';
|
||||||
|
} else if (diffInHours < 24) {
|
||||||
|
return `${diffInHours}h ago`;
|
||||||
|
} else if (diffInHours < 168) {
|
||||||
|
return `${Math.floor(diffInHours / 24)}d ago`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentPreview = (content: any): string => {
|
||||||
|
if (!content || !content.content) return '';
|
||||||
|
|
||||||
|
const extractText = (nodes: any[]): string => {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
if (node.text) return node.text;
|
||||||
|
if (node.content) return extractText(node.content);
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = extractText(content.content);
|
||||||
|
return text.length > 150 ? `${text.substring(0, 150)}...` : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/threads/${thread.threadId}`}
|
||||||
|
className="block group transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="bg-lfg-oxford border border-lfg-charcoal rounded-lg p-6 hover:border-lfg-lavender hover:shadow-md hover:shadow-lfg-lavender/10 transition-all duration-200">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-lfg-rich-black rounded-lg">
|
||||||
|
<MessageCircle size={20} className="text-lfg-lavender" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
{thread.isPinned && (
|
||||||
|
<Pin size={16} className="text-lfg-lavender" />
|
||||||
|
)}
|
||||||
|
{thread.isLocked && (
|
||||||
|
<Lock size={16} className="text-lfg-charcoal" />
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold text-lfg-lavender group-hover:text-white transition-colors truncate">
|
||||||
|
{thread.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getContentPreview(thread.richContent) && (
|
||||||
|
<p className="text-lfg-charcoal text-sm mb-3 line-clamp-2">
|
||||||
|
{getContentPreview(thread.richContent)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-lfg-charcoal">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<User size={14} />
|
||||||
|
<span>{thread.authorUsername}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<MessageCircle size={14} />
|
||||||
|
<span>{thread.postCount || 0} replies</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>{formatDate(thread.lastPostAt || thread.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{thread.attachedFiles && thread.attachedFiles.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-lfg-charcoal">
|
||||||
|
<div className="flex items-center space-x-1 text-xs text-lfg-charcoal">
|
||||||
|
<span>📎</span>
|
||||||
|
<span>{thread.attachedFiles.length} attachment{thread.attachedFiles.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadCard;
|
||||||
132
frontend/src/contexts/AuthContext.tsx
Normal file
132
frontend/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { AuthUser, ApiResponse } from '../types';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextType => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const userData = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (token && userData) {
|
||||||
|
try {
|
||||||
|
const parsedUser = JSON.parse(userData);
|
||||||
|
setUser({
|
||||||
|
...parsedUser,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ApiResponse<{ user: AuthUser; token: string }> = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.data) {
|
||||||
|
throw new Error(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: userData, token } = data.data;
|
||||||
|
const authUser: AuthUser = {
|
||||||
|
...userData,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(authUser);
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (username: string, email: string, password: string): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ApiResponse<{ user: AuthUser; token: string }> = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.data) {
|
||||||
|
throw new Error(data.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: userData, token } = data.data;
|
||||||
|
const authUser: AuthUser = {
|
||||||
|
...userData,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(authUser);
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = (): void => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
loading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
102
frontend/src/contexts/MockAuthContext.tsx
Normal file
102
frontend/src/contexts/MockAuthContext.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextType => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock user for testing
|
||||||
|
const mockUser: AuthUser = {
|
||||||
|
userId: 'mock-user-id',
|
||||||
|
username: 'TestUser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
token: 'mock-jwt-token',
|
||||||
|
isAdmin: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Simulate API call delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Accept any email/password for demo
|
||||||
|
setUser(mockUser);
|
||||||
|
localStorage.setItem('token', mockUser.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(mockUser));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (username: string, email: string, password: string): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Simulate API call delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
...mockUser,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(newUser);
|
||||||
|
localStorage.setItem('token', newUser.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(newUser));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = (): void => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
loading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
192
frontend/src/index.css
Normal file
192
frontend/src/index.css
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background-color: #0C1821;
|
||||||
|
color: #CCC9DC;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #CCC9DC;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #CCC9DC;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(204, 201, 220, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: #1B2A41;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #324A5F;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #CCC9DC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rich text editor styles */
|
||||||
|
.ProseMirror {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #CCC9DC;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #1B2A41;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(204, 201, 220, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h1, .ProseMirror h2, .ProseMirror h3, .ProseMirror h4, .ProseMirror h5, .ProseMirror h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #CCC9DC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror blockquote {
|
||||||
|
border-left: 4px solid #CCC9DC;
|
||||||
|
padding-left: 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #324A5F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror code {
|
||||||
|
background-color: #1B2A41;
|
||||||
|
color: #CCC9DC;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror pre {
|
||||||
|
background-color: #000000;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul, .ProseMirror ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror a {
|
||||||
|
color: #CCC9DC;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror a:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mention styling */
|
||||||
|
.mention {
|
||||||
|
background-color: #CCC9DC;
|
||||||
|
color: #000000;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading animations */
|
||||||
|
.loading-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
background-color: #1B2A41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid #324A5F;
|
||||||
|
border-top-color: #CCC9DC;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
206
frontend/src/pages/Categories.tsx
Normal file
206
frontend/src/pages/Categories.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Plus, Search } from 'lucide-react';
|
||||||
|
import CategoryCard from '../components/CategoryCard';
|
||||||
|
import { type Category, type ApiResponse } from '../types/index';
|
||||||
|
import { useAuth } from '../contexts/MockAuthContext';
|
||||||
|
|
||||||
|
const Categories: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
// Mock data for demonstration
|
||||||
|
const mockCategories: Category[] = [
|
||||||
|
{
|
||||||
|
categoryId: '1',
|
||||||
|
name: 'General Gaming',
|
||||||
|
description: 'Discuss all things gaming - from the latest releases to classic favorites.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 45,
|
||||||
|
lastActivity: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '2',
|
||||||
|
name: 'PC Gaming',
|
||||||
|
description: 'Everything related to PC gaming, hardware, and performance optimization.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 32,
|
||||||
|
lastActivity: new Date(Date.now() - 7200000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '3',
|
||||||
|
name: 'Console Gaming',
|
||||||
|
description: 'PlayStation, Xbox, Nintendo Switch - all console gaming discussions.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 28,
|
||||||
|
lastActivity: new Date(Date.now() - 1800000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '4',
|
||||||
|
name: 'LFG (Looking for Group)',
|
||||||
|
description: 'Find teammates and gaming partners for multiplayer games.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 67,
|
||||||
|
lastActivity: new Date(Date.now() - 900000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '5',
|
||||||
|
name: 'Game Reviews',
|
||||||
|
description: 'Share your thoughts and reviews on the latest games.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 23,
|
||||||
|
lastActivity: new Date(Date.now() - 10800000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '6',
|
||||||
|
name: 'Tech Support',
|
||||||
|
description: 'Get help with gaming hardware, software issues, and troubleshooting.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 15,
|
||||||
|
lastActivity: new Date(Date.now() - 5400000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '7',
|
||||||
|
name: 'Esports',
|
||||||
|
description: 'Professional gaming, tournaments, and competitive play discussions.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 38,
|
||||||
|
lastActivity: new Date(Date.now() - 2700000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '8',
|
||||||
|
name: 'Mobile Gaming',
|
||||||
|
description: 'iOS and Android games, mobile gaming news, and recommendations.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 19,
|
||||||
|
lastActivity: new Date(Date.now() - 4500000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '9',
|
||||||
|
name: 'Game Development',
|
||||||
|
description: 'For aspiring and professional game developers to share and collaborate.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 12,
|
||||||
|
lastActivity: new Date(Date.now() - 6300000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
setCategories(mockCategories);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading mock categories:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredCategories = categories.filter(category =>
|
||||||
|
category.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
category.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="container py-12">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-lfg-oxford rounded mb-8 w-64"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i} className="h-48 bg-lfg-oxford rounded-lg"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="container py-12">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-lfg-lavender">Forum Categories</h1>
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/admin/categories/new"
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-lfg-lavender text-lfg-black rounded-lg hover:bg-white transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>New Category</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search categories..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="
|
||||||
|
w-full px-4 py-2 pl-10 pr-4
|
||||||
|
bg-lfg-oxford border border-lfg-charcoal rounded-lg
|
||||||
|
text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Search
|
||||||
|
size={20}
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-lfg-charcoal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredCategories.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal">
|
||||||
|
<h2 className="text-xl font-semibold text-lfg-lavender mb-2">No Categories Found</h2>
|
||||||
|
<p className="text-lfg-charcoal mb-4">
|
||||||
|
{searchTerm ? 'No categories match your search.' : 'No categories have been created yet.'}
|
||||||
|
</p>
|
||||||
|
{user?.isAdmin && !searchTerm && (
|
||||||
|
<Link
|
||||||
|
to="/admin/categories/new"
|
||||||
|
className="inline-flex items-center space-x-2 px-4 py-2 bg-lfg-lavender text-lfg-black rounded-lg hover:bg-white transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Create First Category</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredCategories.map((category) => (
|
||||||
|
<CategoryCard key={category.categoryId} category={category} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Categories;
|
||||||
16
frontend/src/pages/CategoryDetail.tsx
Normal file
16
frontend/src/pages/CategoryDetail.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CategoryDetail: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold text-lfg-lavender mb-8">Category Detail</h1>
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal text-center">
|
||||||
|
<p className="text-lfg-charcoal">Category detail page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryDetail;
|
||||||
16
frontend/src/pages/CreateThread.tsx
Normal file
16
frontend/src/pages/CreateThread.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CreateThread: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold text-lfg-lavender mb-8">Create New Thread</h1>
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal text-center">
|
||||||
|
<p className="text-lfg-charcoal">Create thread page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateThread;
|
||||||
312
frontend/src/pages/Home.tsx
Normal file
312
frontend/src/pages/Home.tsx
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Plus, TrendingUp, MessageCircle, Users } from 'lucide-react';
|
||||||
|
import CategoryCard from '../components/CategoryCard';
|
||||||
|
import ThreadCard from '../components/ThreadCard';
|
||||||
|
import { type Category, type Thread, type ApiResponse } from '../types/index';
|
||||||
|
import { useAuth } from '../contexts/MockAuthContext';
|
||||||
|
|
||||||
|
const Home: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [recentThreads, setRecentThreads] = useState<Thread[]>([]);
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
totalThreads: 0,
|
||||||
|
totalPosts: 0,
|
||||||
|
totalUsers: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Mock data for demonstration
|
||||||
|
const mockCategories: Category[] = [
|
||||||
|
{
|
||||||
|
categoryId: '1',
|
||||||
|
name: 'General Gaming',
|
||||||
|
description: 'Discuss all things gaming - from the latest releases to classic favorites.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 45,
|
||||||
|
lastActivity: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '2',
|
||||||
|
name: 'PC Gaming',
|
||||||
|
description: 'Everything related to PC gaming, hardware, and performance optimization.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 32,
|
||||||
|
lastActivity: new Date(Date.now() - 7200000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '3',
|
||||||
|
name: 'Console Gaming',
|
||||||
|
description: 'PlayStation, Xbox, Nintendo Switch - all console gaming discussions.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 28,
|
||||||
|
lastActivity: new Date(Date.now() - 1800000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '4',
|
||||||
|
name: 'LFG (Looking for Group)',
|
||||||
|
description: 'Find teammates and gaming partners for multiplayer games.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 67,
|
||||||
|
lastActivity: new Date(Date.now() - 900000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '5',
|
||||||
|
name: 'Game Reviews',
|
||||||
|
description: 'Share your thoughts and reviews on the latest games.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 23,
|
||||||
|
lastActivity: new Date(Date.now() - 10800000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryId: '6',
|
||||||
|
name: 'Tech Support',
|
||||||
|
description: 'Get help with gaming hardware, software issues, and troubleshooting.',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
threadCount: 15,
|
||||||
|
lastActivity: new Date(Date.now() - 5400000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockThreads: Thread[] = [
|
||||||
|
{
|
||||||
|
threadId: '1',
|
||||||
|
categoryId: '1',
|
||||||
|
title: 'What are you playing this weekend?',
|
||||||
|
richContent: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Drop your current gaming plans below! Looking for some new game recommendations.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
authorId: 'user1',
|
||||||
|
authorUsername: 'GamerMike',
|
||||||
|
createdAt: new Date(Date.now() - 1800000).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 1800000).toISOString(),
|
||||||
|
postCount: 12,
|
||||||
|
lastPostAt: new Date(Date.now() - 300000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threadId: '2',
|
||||||
|
categoryId: '4',
|
||||||
|
title: 'LFG: Apex Legends Ranked',
|
||||||
|
richContent: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Looking for 2 teammates for ranked Apex. Currently Platinum 2, aiming for Diamond.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
authorId: 'user2',
|
||||||
|
authorUsername: 'ApexHunter',
|
||||||
|
createdAt: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
postCount: 5,
|
||||||
|
lastPostAt: new Date(Date.now() - 600000).toISOString(),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setCategories(mockCategories);
|
||||||
|
setRecentThreads(mockThreads);
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
totalThreads: 142,
|
||||||
|
totalPosts: 1847,
|
||||||
|
totalUsers: 324,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading mock data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="container py-12">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-32 bg-lfg-oxford rounded-lg mb-8"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div key={i} className="h-48 bg-lfg-oxford rounded-lg"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="container py-12">
|
||||||
|
<div className="bg-gradient-to-r from-lfg-oxford to-lfg-charcoal rounded-2xl p-8 mb-8 border border-lfg-lavender/20">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-lfg-lavender mb-4">
|
||||||
|
Welcome to LFG9 Forums
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-lfg-charcoal mb-6 max-w-2xl mx-auto">
|
||||||
|
Connect with fellow gamers, share strategies, and find your next gaming adventure.
|
||||||
|
Join discussions across multiple game categories and build lasting friendships.
|
||||||
|
</p>
|
||||||
|
{!user && (
|
||||||
|
<div className="flex justify-center space-x-4">
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="px-6 py-3 bg-lfg-lavender text-lfg-black rounded-lg hover:bg-white transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
Join the Community
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="px-6 py-3 border border-lfg-lavender text-lfg-lavender rounded-lg hover:bg-lfg-lavender hover:text-lfg-black transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-6 border border-lfg-charcoal">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-3 bg-lfg-lavender rounded-lg">
|
||||||
|
<MessageCircle className="text-lfg-black" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-lfg-lavender">{stats.totalThreads}</p>
|
||||||
|
<p className="text-lfg-charcoal">Total Threads</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-6 border border-lfg-charcoal">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-3 bg-lfg-lavender rounded-lg">
|
||||||
|
<TrendingUp className="text-lfg-black" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-lfg-lavender">{stats.totalPosts}</p>
|
||||||
|
<p className="text-lfg-charcoal">Total Posts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-6 border border-lfg-charcoal">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-3 bg-lfg-lavender rounded-lg">
|
||||||
|
<Users className="text-lfg-black" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-lfg-lavender">{stats.totalUsers}</p>
|
||||||
|
<p className="text-lfg-charcoal">Community Members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-lfg-lavender">Categories</h2>
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
className="text-lfg-lavender hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
View All →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<CategoryCard key={category.categoryId} category={category} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-6 border border-lfg-charcoal">
|
||||||
|
<h3 className="text-lg font-semibold text-lfg-lavender mb-4">Quick Actions</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
to="/threads/new"
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-lfg-lavender text-lfg-black rounded-lg hover:bg-white transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>New Thread</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
className="px-4 py-2 border border-lfg-lavender text-lfg-lavender rounded-lg hover:bg-lfg-lavender hover:text-lfg-black transition-colors"
|
||||||
|
>
|
||||||
|
Browse Categories
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h2 className="text-xl font-bold text-lfg-lavender">Recent Activity</h2>
|
||||||
|
<Link
|
||||||
|
to="/recent"
|
||||||
|
className="text-lfg-lavender hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
View All →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{recentThreads.map((thread) => (
|
||||||
|
<ThreadCard key={thread.threadId} thread={thread} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentThreads.length === 0 && (
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal text-center">
|
||||||
|
<MessageCircle size={48} className="mx-auto text-lfg-charcoal mb-4" />
|
||||||
|
<p className="text-lfg-charcoal">No recent threads yet.</p>
|
||||||
|
{user && (
|
||||||
|
<p className="text-lfg-charcoal mt-2">Be the first to start a discussion!</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
166
frontend/src/pages/Login.tsx
Normal file
166
frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Eye, EyeOff, LogIn } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/MockAuthContext';
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const from = (location.state as any)?.from?.pathname || '/';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message || 'Login failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 bg-lfg-lavender rounded-full flex items-center justify-center mb-4">
|
||||||
|
<LogIn className="h-6 w-6 text-lfg-black" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-lfg-lavender">Sign in to your account</h2>
|
||||||
|
<p className="mt-2 text-sm text-lfg-charcoal">
|
||||||
|
Or{' '}
|
||||||
|
<Link to="/register" className="font-medium text-lfg-lavender hover:text-white">
|
||||||
|
create a new account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-lfg-lavender mb-2">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-lfg-charcoal rounded-lg
|
||||||
|
bg-lfg-oxford text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-lfg-lavender mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 pr-10 border border-lfg-charcoal rounded-lg
|
||||||
|
bg-lfg-oxford text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-lfg-charcoal hover:text-lfg-lavender"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-lfg-lavender focus:ring-lfg-lavender border-lfg-charcoal rounded bg-lfg-oxford"
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-me" className="ml-2 block text-sm text-lfg-charcoal">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<Link to="/forgot-password" className="font-medium text-lfg-lavender hover:text-white">
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="
|
||||||
|
group relative w-full flex justify-center py-2 px-4 border border-transparent
|
||||||
|
text-sm font-medium rounded-lg text-lfg-black bg-lfg-lavender
|
||||||
|
hover:bg-white focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
|
focus:ring-lfg-lavender disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-spinner w-5 h-5"></div>
|
||||||
|
) : (
|
||||||
|
'Sign in'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-lfg-charcoal text-sm">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="font-medium text-lfg-lavender hover:text-white">
|
||||||
|
Sign up here
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
16
frontend/src/pages/Profile.tsx
Normal file
16
frontend/src/pages/Profile.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Profile: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold text-lfg-lavender mb-8">User Profile</h1>
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal text-center">
|
||||||
|
<p className="text-lfg-charcoal">Profile page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
271
frontend/src/pages/Register.tsx
Normal file
271
frontend/src/pages/Register.tsx
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Eye, EyeOff, UserPlus } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/MockAuthContext';
|
||||||
|
|
||||||
|
const Register: React.FC = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [passwordStrength, setPasswordStrength] = useState({
|
||||||
|
length: false,
|
||||||
|
uppercase: false,
|
||||||
|
lowercase: false,
|
||||||
|
number: false,
|
||||||
|
special: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { register } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const checkPasswordStrength = (password: string) => {
|
||||||
|
setPasswordStrength({
|
||||||
|
length: password.length >= 8,
|
||||||
|
uppercase: /[A-Z]/.test(password),
|
||||||
|
lowercase: /[a-z]/.test(password),
|
||||||
|
number: /\d/.test(password),
|
||||||
|
special: /[@$!%*?&]/.test(password),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
|
||||||
|
if (name === 'password') {
|
||||||
|
checkPasswordStrength(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStrengthPassed = Object.values(passwordStrength).every(Boolean);
|
||||||
|
if (!allStrengthPassed) {
|
||||||
|
setError('Password does not meet all requirements');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(formData.username, formData.email, formData.password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message || 'Registration failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 bg-lfg-lavender rounded-full flex items-center justify-center mb-4">
|
||||||
|
<UserPlus className="h-6 w-6 text-lfg-black" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-lfg-lavender">Create your account</h2>
|
||||||
|
<p className="mt-2 text-sm text-lfg-charcoal">
|
||||||
|
Or{' '}
|
||||||
|
<Link to="/login" className="font-medium text-lfg-lavender hover:text-white">
|
||||||
|
sign in to your existing account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-lfg-lavender mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-lfg-charcoal rounded-lg
|
||||||
|
bg-lfg-oxford text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-lfg-charcoal">
|
||||||
|
3-30 characters, alphanumeric only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-lfg-lavender mb-2">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 border border-lfg-charcoal rounded-lg
|
||||||
|
bg-lfg-oxford text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-lfg-lavender mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 pr-10 border border-lfg-charcoal rounded-lg
|
||||||
|
bg-lfg-oxford text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
placeholder="Create a password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-lfg-charcoal hover:text-lfg-lavender"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.password && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="text-xs text-lfg-charcoal">Password requirements:</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||||
|
<div className={`flex items-center ${passwordStrength.length ? 'text-green-400' : 'text-lfg-charcoal'}`}>
|
||||||
|
<span className="mr-1">{passwordStrength.length ? '✓' : '○'}</span>
|
||||||
|
8+ characters
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center ${passwordStrength.uppercase ? 'text-green-400' : 'text-lfg-charcoal'}`}>
|
||||||
|
<span className="mr-1">{passwordStrength.uppercase ? '✓' : '○'}</span>
|
||||||
|
Uppercase
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center ${passwordStrength.lowercase ? 'text-green-400' : 'text-lfg-charcoal'}`}>
|
||||||
|
<span className="mr-1">{passwordStrength.lowercase ? '✓' : '○'}</span>
|
||||||
|
Lowercase
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center ${passwordStrength.number ? 'text-green-400' : 'text-lfg-charcoal'}`}>
|
||||||
|
<span className="mr-1">{passwordStrength.number ? '✓' : '○'}</span>
|
||||||
|
Number
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center ${passwordStrength.special ? 'text-green-400' : 'text-lfg-charcoal'} col-span-2`}>
|
||||||
|
<span className="mr-1">{passwordStrength.special ? '✓' : '○'}</span>
|
||||||
|
Special character (@$!%*?&)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-lfg-lavender mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="
|
||||||
|
w-full px-3 py-2 pr-10 border border-lfg-charcoal rounded-lg
|
||||||
|
bg-lfg-oxford text-lfg-lavender placeholder-lfg-charcoal
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-lfg-lavender focus:ring-opacity-50
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-lfg-charcoal hover:text-lfg-lavender"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Passwords do not match</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="
|
||||||
|
group relative w-full flex justify-center py-2 px-4 border border-transparent
|
||||||
|
text-sm font-medium rounded-lg text-lfg-black bg-lfg-lavender
|
||||||
|
hover:bg-white focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
|
focus:ring-lfg-lavender disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-200
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-spinner w-5 h-5"></div>
|
||||||
|
) : (
|
||||||
|
'Create Account'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-lfg-charcoal text-sm">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="font-medium text-lfg-lavender hover:text-white">
|
||||||
|
Sign in here
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
16
frontend/src/pages/Settings.tsx
Normal file
16
frontend/src/pages/Settings.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold text-lfg-lavender mb-8">Settings</h1>
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal text-center">
|
||||||
|
<p className="text-lfg-charcoal">Settings page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
16
frontend/src/pages/ThreadDetail.tsx
Normal file
16
frontend/src/pages/ThreadDetail.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ThreadDetail: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-lfg-rich-black">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold text-lfg-lavender mb-8">Thread Detail</h1>
|
||||||
|
<div className="bg-lfg-oxford rounded-lg p-8 border border-lfg-charcoal text-center">
|
||||||
|
<p className="text-lfg-charcoal">Thread detail page coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadDetail;
|
||||||
104
frontend/src/types/index.ts
Normal file
104
frontend/src/types/index.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// TipTap types
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
profileInfo?: {
|
||||||
|
bio?: string;
|
||||||
|
avatar?: string;
|
||||||
|
location?: string;
|
||||||
|
};
|
||||||
|
storageQuotaUsed: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
threadCount?: number;
|
||||||
|
lastActivity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichContent {
|
||||||
|
type: 'doc';
|
||||||
|
content: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
threadId: string;
|
||||||
|
categoryId: string;
|
||||||
|
title: string;
|
||||||
|
richContent: RichContent;
|
||||||
|
authorId: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
attachedFiles?: FileMetadata[];
|
||||||
|
postCount?: number;
|
||||||
|
lastPostAt?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
isPinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
postId: string;
|
||||||
|
threadId: string;
|
||||||
|
richContent: RichContent;
|
||||||
|
authorId: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
parentPostId?: string;
|
||||||
|
attachedFiles?: FileMetadata[];
|
||||||
|
isEdited?: boolean;
|
||||||
|
editedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
fileId: string;
|
||||||
|
userId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
fileSize: number;
|
||||||
|
s3Key: string;
|
||||||
|
threadId?: string;
|
||||||
|
postId?: string;
|
||||||
|
uploadDate: string;
|
||||||
|
thumbnailKey?: string;
|
||||||
|
mediumKey?: string;
|
||||||
|
altText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchParams {
|
||||||
|
query: string;
|
||||||
|
categoryId?: string;
|
||||||
|
authorId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
49
frontend/tailwind.config.js
Normal file
49
frontend/tailwind.config.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: {
|
||||||
|
DEFAULT: '1rem',
|
||||||
|
sm: '1.5rem',
|
||||||
|
lg: '2rem',
|
||||||
|
xl: '3rem',
|
||||||
|
'2xl': '4rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'lfg': {
|
||||||
|
'black': '#000000',
|
||||||
|
'rich-black': '#0C1821',
|
||||||
|
'oxford': '#1B2A41',
|
||||||
|
'charcoal': '#324A5F',
|
||||||
|
'lavender': '#CCC9DC',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
frontend/tsconfig.node.json
Normal file
25
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
1669
package-lock.json
generated
Normal file
1669
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "lfg9-forums",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A modern full-stack forum application",
|
||||||
|
"scripts": {
|
||||||
|
"setup": "npm install && cd frontend && npm install && cd ../backend && npm install",
|
||||||
|
"setup-db": "node setup-dev-tables.js",
|
||||||
|
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||||
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
"dev:frontend": "cd frontend && npm run dev",
|
||||||
|
"build": "cd frontend && npm run build && cd ../backend && npm run build",
|
||||||
|
"start": "cd backend && npm start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.400.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"keywords": ["forum", "react", "node", "typescript", "aws"],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
128
setup-dev-tables.js
Normal file
128
setup-dev-tables.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
|
||||||
|
const { CreateTableCommand } = require('@aws-sdk/client-dynamodb');
|
||||||
|
|
||||||
|
const client = new DynamoDBClient({
|
||||||
|
region: process.env.AWS_REGION || 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tablePrefix = 'lfg9_forums_dev_';
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
{
|
||||||
|
name: `${tablePrefix}users`,
|
||||||
|
keySchema: [{ AttributeName: 'userId', KeyType: 'HASH' }],
|
||||||
|
attributeDefinitions: [{ AttributeName: 'userId', AttributeType: 'S' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${tablePrefix}categories`,
|
||||||
|
keySchema: [{ AttributeName: 'categoryId', KeyType: 'HASH' }],
|
||||||
|
attributeDefinitions: [{ AttributeName: 'categoryId', AttributeType: 'S' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${tablePrefix}threads`,
|
||||||
|
keySchema: [{ AttributeName: 'threadId', KeyType: 'HASH' }],
|
||||||
|
attributeDefinitions: [
|
||||||
|
{ AttributeName: 'threadId', AttributeType: 'S' },
|
||||||
|
{ AttributeName: 'categoryId', AttributeType: 'S' },
|
||||||
|
{ AttributeName: 'authorId', AttributeType: 'S' },
|
||||||
|
],
|
||||||
|
globalSecondaryIndexes: [
|
||||||
|
{
|
||||||
|
IndexName: 'CategoryIdIndex',
|
||||||
|
KeySchema: [{ AttributeName: 'categoryId', KeyType: 'HASH' }],
|
||||||
|
Projection: { ProjectionType: 'ALL' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IndexName: 'AuthorIdIndex',
|
||||||
|
KeySchema: [{ AttributeName: 'authorId', KeyType: 'HASH' }],
|
||||||
|
Projection: { ProjectionType: 'ALL' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${tablePrefix}posts`,
|
||||||
|
keySchema: [{ AttributeName: 'postId', KeyType: 'HASH' }],
|
||||||
|
attributeDefinitions: [
|
||||||
|
{ AttributeName: 'postId', AttributeType: 'S' },
|
||||||
|
{ AttributeName: 'threadId', AttributeType: 'S' },
|
||||||
|
{ AttributeName: 'authorId', AttributeType: 'S' },
|
||||||
|
],
|
||||||
|
globalSecondaryIndexes: [
|
||||||
|
{
|
||||||
|
IndexName: 'ThreadIdIndex',
|
||||||
|
KeySchema: [{ AttributeName: 'threadId', KeyType: 'HASH' }],
|
||||||
|
Projection: { ProjectionType: 'ALL' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IndexName: 'AuthorIdIndex',
|
||||||
|
KeySchema: [{ AttributeName: 'authorId', KeyType: 'HASH' }],
|
||||||
|
Projection: { ProjectionType: 'ALL' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${tablePrefix}files`,
|
||||||
|
keySchema: [{ AttributeName: 'fileId', KeyType: 'HASH' }],
|
||||||
|
attributeDefinitions: [
|
||||||
|
{ AttributeName: 'fileId', AttributeType: 'S' },
|
||||||
|
{ AttributeName: 'userId', AttributeType: 'S' },
|
||||||
|
],
|
||||||
|
globalSecondaryIndexes: [
|
||||||
|
{
|
||||||
|
IndexName: 'UserIdIndex',
|
||||||
|
KeySchema: [{ AttributeName: 'userId', KeyType: 'HASH' }],
|
||||||
|
Projection: { ProjectionType: 'ALL' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function createTable(tableConfig) {
|
||||||
|
const params = {
|
||||||
|
TableName: tableConfig.name,
|
||||||
|
KeySchema: tableConfig.keySchema,
|
||||||
|
AttributeDefinitions: tableConfig.attributeDefinitions,
|
||||||
|
BillingMode: 'PAY_PER_REQUEST',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tableConfig.globalSecondaryIndexes) {
|
||||||
|
params.GlobalSecondaryIndexes = tableConfig.globalSecondaryIndexes.map(gsi => ({
|
||||||
|
...gsi,
|
||||||
|
BillingMode: 'PAY_PER_REQUEST',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateTableCommand(params);
|
||||||
|
const result = await client.send(command);
|
||||||
|
console.log(`✅ Created table: ${tableConfig.name}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'ResourceInUseException') {
|
||||||
|
console.log(`⚠️ Table ${tableConfig.name} already exists`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Error creating table ${tableConfig.name}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupTables() {
|
||||||
|
console.log('🚀 Setting up DynamoDB tables for development...\n');
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
await createTable(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✨ Database setup complete!');
|
||||||
|
console.log('🔗 You can view your tables in the AWS Console:');
|
||||||
|
console.log('https://console.aws.amazon.com/dynamodb/home?region=us-east-1#tables:');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
require('dotenv').config({ path: './backend/.env' });
|
||||||
|
|
||||||
|
setupTables().catch(console.error);
|
||||||
Loading…
Reference in New Issue
Block a user