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