Initial commit: Blog Editor application

This commit is contained in:
true1ck 2026-02-08 03:59:53 +05:30
commit a949eb8e57
45 changed files with 4249 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
package-lock.json
# Environment variables
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
*.local
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Temporary files
*.tmp
.cache/

127
ENV_EXAMPLES.md Normal file
View File

@ -0,0 +1,127 @@
# Environment Variables Examples
## Backend Environment Variables
Create a `.env` file in `blog-editor/backend/` with the following:
```env
# =====================================================
# SERVER CONFIGURATION
# =====================================================
PORT=5000
NODE_ENV=development
# =====================================================
# DATABASE CONFIGURATION (PostgreSQL - Supabase)
# =====================================================
# Option 1: Use Supabase connection string (recommended)
# Format: postgresql://user:password@host:port/database
DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres
# Option 2: Use individual parameters (for local development)
# Uncomment and use these if not using DATABASE_URL
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=blog_editor
# DB_USER=postgres
# DB_PASSWORD=your_database_password_here
# =====================================================
# AUTH SERVICE INTEGRATION
# =====================================================
# URL of your existing auth service
# The blog editor validates JWT tokens via this service
AUTH_SERVICE_URL=http://localhost:3000
# =====================================================
# AWS S3 CONFIGURATION (for image uploads)
# =====================================================
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_aws_access_key_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
S3_BUCKET_NAME=blog-editor-images
# =====================================================
# CORS CONFIGURATION
# =====================================================
# Frontend URL that will make requests to this backend
CORS_ORIGIN=http://localhost:4000
# Production example:
# CORS_ORIGIN=https://your-frontend-domain.com
```
## Frontend Environment Variables
Create a `.env` file in `blog-editor/frontend/` with the following:
```env
# =====================================================
# BLOG EDITOR BACKEND API URL
# =====================================================
# URL of the blog editor backend API
# This is where posts, uploads, etc. are handled
VITE_API_URL=http://localhost:5001
# Production example:
# VITE_API_URL=https://api.yourdomain.com
# =====================================================
# AUTH SERVICE API URL
# =====================================================
# URL of your existing auth service
# This is where authentication (login, OTP, etc.) is handled
VITE_AUTH_API_URL=http://localhost:3000
# Production example:
# VITE_AUTH_API_URL=https://auth.yourdomain.com
```
## Quick Setup
### Backend
```bash
cd blog-editor/backend
cp env.example .env
# Edit .env with your actual values
```
### Frontend
```bash
cd blog-editor/frontend
cp env.example .env
# Edit .env with your actual values
```
## Required Values to Update
### Backend `.env`
- `DATABASE_URL` - **Supabase connection string** (replace `[YOUR-PASSWORD]` with actual password)
- Format: `postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres`
- Or use individual DB_* parameters for local development
- `AUTH_SERVICE_URL` - URL where your auth service is running (default: http://localhost:3000)
- **Note:** Auth service uses its own separate database
- `AWS_ACCESS_KEY_ID` - Your AWS access key
- `AWS_SECRET_ACCESS_KEY` - Your AWS secret key
- `S3_BUCKET_NAME` - Your S3 bucket name
- `CORS_ORIGIN` - Your frontend URL (default: http://localhost:4000)
### Frontend `.env`
- `VITE_API_URL` - Your blog editor backend URL (default: http://localhost:5001)
- `VITE_AUTH_API_URL` - Your auth service URL (default: http://localhost:3000)
## Notes
1. **VITE_ prefix**: Frontend environment variables must start with `VITE_` to be accessible in the code
2. **Database (Supabase)**:
- Replace `[YOUR-PASSWORD]` in `DATABASE_URL` with your actual Supabase password
- Supabase automatically handles SSL connections
- The connection string uses Supabase's connection pooler
- Make sure the database exists in Supabase (or use default `postgres` database)
3. **Auth Service**:
- Ensure your auth service is running on the port specified in `AUTH_SERVICE_URL`
- **Important:** Auth service uses its own separate database (not Supabase)
4. **AWS S3**:
- Create an S3 bucket
- Configure CORS to allow PUT requests from your frontend
- Create IAM user with `s3:PutObject` and `s3:GetObject` permissions

91
INTEGRATION.md Normal file
View File

@ -0,0 +1,91 @@
# Auth Service Integration
The blog editor is integrated with the existing auth service located at `G:\LivingAi\GITTEA_RPO\auth`.
## How It Works
### Backend Integration
The blog editor backend validates JWT tokens by calling the auth service's `/auth/validate-token` endpoint:
1. Client sends request with `Authorization: Bearer <token>` header
2. Blog editor backend middleware (`middleware/auth.js`) extracts the token
3. Middleware calls `POST /auth/validate-token` on the auth service
4. Auth service validates the token and returns user info
5. Blog editor backend sets `req.user` and continues processing
### Frontend Integration
The frontend uses the auth service directly for authentication:
1. **Login Flow:**
- User enters phone number
- Frontend calls `POST /auth/request-otp` on auth service
- User enters OTP
- Frontend calls `POST /auth/verify-otp` on auth service
- Auth service returns `access_token` and `refresh_token`
- Frontend stores tokens in localStorage
2. **API Requests:**
- Frontend includes `Authorization: Bearer <access_token>` header
- Blog editor backend validates token via auth service
- If token expires, frontend automatically refreshes using `refresh_token`
## Configuration
### Backend (.env)
```env
AUTH_SERVICE_URL=http://localhost:3000
```
### Frontend (.env)
```env
VITE_AUTH_API_URL=http://localhost:3000
```
## Token Storage
- `access_token` - Stored in localStorage, used for API requests
- `refresh_token` - Stored in localStorage, used to refresh access token
- `user` - User object stored in localStorage
## Authentication Flow
```
┌─────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │────────▶│ Auth Service │────────▶│ Blog Editor │
│ │ │ │ │ Backend │
└─────────┘ └──────────────┘ └─────────────┘
│ │ │
│ 1. Request OTP │ │
│◀─────────────────────│ │
│ │ │
│ 2. Verify OTP │ │
│─────────────────────▶│ │
│ 3. Get Tokens │ │
│◀─────────────────────│ │
│ │ │
│ 4. API Request │ │
│──────────────────────────────────────────────▶│
│ │ 5. Validate Token │
│ │◀───────────────────────│
│ │ 6. User Info │
│ │───────────────────────▶│
│ 7. Response │ │
│◀──────────────────────────────────────────────│
```
## Benefits
1. **Single Source of Truth:** All authentication handled by one service
2. **Consistent Security:** Same JWT validation across all services
3. **Token Rotation:** Auth service handles token refresh and rotation
4. **User Management:** Centralized user management in auth service
5. **Guest Support:** Auth service supports guest users
## Notes
- The blog editor backend does NOT handle user registration/login
- All authentication is delegated to the auth service
- The blog editor only validates tokens, not creates them
- Phone/OTP authentication is used (not email/password)

66
QUICK_START.md Normal file
View File

@ -0,0 +1,66 @@
# Quick Start Guide
## Prerequisites Check
- [ ] Node.js 18+ installed
- [ ] PostgreSQL installed and running
- [ ] AWS account with S3 bucket created
- [ ] AWS IAM user with S3 permissions
## 5-Minute Setup
### 1. Backend Setup (2 minutes)
```bash
cd backend
npm install
cp .env.example .env
# Edit .env with your database and AWS credentials
createdb blog_editor # or use psql to create database
npm run migrate
npm run dev
```
### 2. Frontend Setup (2 minutes)
```bash
cd frontend
npm install
cp .env.example .env
# Edit .env: VITE_API_URL=http://localhost:5001
npm run dev
```
### 3. Test the Application (1 minute)
1. Open http://localhost:4000
2. Register a new account
3. Create a new post
4. Add some content with formatting
5. Upload an image
6. Publish the post
## Common Issues
### Database Connection Error
- Check PostgreSQL is running: `pg_isready`
- Verify credentials in `.env`
- Ensure database exists: `psql -l | grep blog_editor`
### S3 Upload Fails
- Verify AWS credentials in `.env`
- Check S3 bucket name is correct
- Ensure bucket CORS is configured
- Verify IAM user has PutObject permission
### CORS Error
- Check `CORS_ORIGIN` in backend `.env` matches frontend URL
- Default: `http://localhost:4000`
## Next Steps
- Customize the editor styling
- Add more TipTap extensions
- Configure production environment variables
- Set up CI/CD pipeline
- Deploy to AWS

259
README.md Normal file
View File

@ -0,0 +1,259 @@
# Blog Editor - Full Stack Application
A full-stack blog editor built with TipTap, React, Node.js, PostgreSQL, and AWS S3.
## Features
- ✨ Rich text editor with TipTap
- 📝 Auto-save drafts every 10 seconds
- 🖼️ Image upload to AWS S3 with presigned URLs
- 🔐 Phone/OTP authentication (integrated with existing auth service)
- 📱 Mobile responsive UI
- 🎨 Rich formatting options (Bold, Italic, Underline, Headings, Lists, Quotes, Code blocks, Colors, Font sizes)
- 📄 Public blog pages
- 🎯 Dashboard for managing posts
## Tech Stack
### Frontend
- React 18
- Vite
- TipTap Editor
- Tailwind CSS
- React Router
- Axios
### Backend
- Node.js
- Express
- PostgreSQL
- JWT + bcrypt
- AWS S3 SDK
## Project Structure
```
blog-editor/
├── frontend/ # React + Vite application
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ ├── contexts/ # React contexts
│ │ ├── utils/ # Utility functions
│ │ └── extensions/ # TipTap extensions
│ └── package.json
├── backend/ # Express API
│ ├── config/ # Database and S3 config
│ ├── routes/ # API routes
│ ├── middleware/ # Auth middleware
│ ├── migrations/ # Database migrations
│ └── package.json
└── README.md
```
## Setup Instructions
### Prerequisites
- Node.js 18+
- PostgreSQL 12+
- AWS Account with S3 bucket
- AWS Access Key and Secret Key
### Backend Setup
1. Navigate to backend directory:
```bash
cd backend
```
2. Install dependencies:
```bash
npm install
```
3. Create `.env` file:
```env
PORT=5000
DB_HOST=localhost
DB_PORT=5432
DB_NAME=blog_editor
DB_USER=postgres
DB_PASSWORD=your_password
AUTH_SERVICE_URL=http://localhost:3000
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
S3_BUCKET_NAME=blog-editor-images
CORS_ORIGIN=http://localhost:4000
```
**Note:** The blog editor uses the existing auth service at `G:\LivingAi\GITTEA_RPO\auth`. Make sure:
- The auth service is running on port 3000 (or update `AUTH_SERVICE_URL`)
- **Auth service uses its own separate database** (not Supabase)
- Blog editor uses Supabase for storing posts
5. Configure Supabase database:
- Add your Supabase connection string to `.env` as `DATABASE_URL`
- Format: `postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres`
- Replace `[YOUR-PASSWORD]` with your actual Supabase password
6. Run migrations:
```bash
npm run migrate
```
7. Start the server:
```bash
npm run dev
```
The backend will run on `http://localhost:5001`
### Frontend Setup
1. Navigate to frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Create `.env` file:
```bash
cp .env.example .env
```
4. Update `.env`:
```env
VITE_API_URL=http://localhost:5001
VITE_AUTH_API_URL=http://localhost:3000
```
**Note:** `VITE_AUTH_API_URL` should point to your existing auth service.
5. Start the development server:
```bash
npm run dev
```
The frontend will run on `http://localhost:4000`
## AWS S3 Setup
1. Create an S3 bucket in your AWS account
2. Configure CORS for the bucket:
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedOrigins": ["http://localhost:4000"],
"ExposeHeaders": []
}
]
```
3. Create an IAM user with S3 permissions:
- `s3:PutObject` on your bucket
- `s3:GetObject` on your bucket
4. Add the Access Key ID and Secret Access Key to your backend `.env` file
## Database Schema
**Note:** Users are managed by the auth service in a separate database. The blog editor only stores `user_id` references.
### Posts Table
- `id` (UUID, Primary Key)
- `user_id` (UUID) - References user ID from auth service (no foreign key constraint)
- `title` (VARCHAR)
- `content_json` (JSONB) - TipTap editor content stored as JSON
- `slug` (VARCHAR, Unique) - URL-friendly post identifier
- `status` (VARCHAR: 'draft' or 'published')
- `created_at` (TIMESTAMP)
- `updated_at` (TIMESTAMP) - Auto-updated via trigger
## API Endpoints
### Authentication (Handled by Existing Auth Service)
The blog editor uses the existing auth service at `G:\LivingAi\GITTEA_RPO\auth`:
- `POST /auth/request-otp` - Request OTP for phone number
- `POST /auth/verify-otp` - Verify OTP and get tokens
- `POST /auth/refresh` - Refresh access token
- `POST /auth/logout` - Logout user
- `POST /auth/validate-token` - Validate JWT token (used by blog editor backend)
### Posts
- `GET /api/posts` - Get all posts for current user (protected)
- `GET /api/posts/:id` - Get single post (protected)
- `GET /api/posts/slug/:slug` - Get post by slug (public)
- `POST /api/posts` - Create new post (protected)
- `PUT /api/posts/:id` - Update post (protected)
- `DELETE /api/posts/:id` - Delete post (protected)
### Upload
- `POST /api/upload/presigned-url` - Get presigned URL for image upload (protected)
## Pages
- `/login` - Login page (Phone/OTP authentication)
- `/dashboard` - User dashboard (protected)
- `/editor` - Create new post (protected)
- `/editor/:id` - Edit existing post (protected)
- `/blog/:slug` - Public blog post view
## Deployment
### Backend (AWS EC2)
1. Set up EC2 instance
2. Install Node.js and PostgreSQL
3. Clone repository
4. Set environment variables
5. Run migrations
6. Use PM2 or similar to run the server:
```bash
pm2 start server.js --name blog-editor-api
```
### Frontend (AWS Amplify or Vercel)
#### AWS Amplify:
1. Connect your repository
2. Set build settings:
- Build command: `npm run build`
- Output directory: `dist`
3. Add environment variable: `VITE_API_URL`
#### Vercel:
1. Import your repository
2. Set build command: `npm run build`
3. Add environment variable: `VITE_API_URL`
## Environment Variables
### Backend
- `PORT` - Server port (default: 5000)
- `DB_HOST` - PostgreSQL host
- `DB_PORT` - PostgreSQL port
- `DB_NAME` - Database name
- `DB_USER` - Database user
- `DB_PASSWORD` - Database password
- `AUTH_SERVICE_URL` - URL of existing auth service (default: http://localhost:3000)
- `AWS_REGION` - AWS region
- `AWS_ACCESS_KEY_ID` - AWS access key
- `AWS_SECRET_ACCESS_KEY` - AWS secret key
- `S3_BUCKET_NAME` - S3 bucket name
- `CORS_ORIGIN` - CORS allowed origin
### Frontend
- `VITE_API_URL` - Blog editor backend API URL
- `VITE_AUTH_API_URL` - Auth service API URL (default: http://localhost:3000)
## License
MIT

219
RUN_APPLICATION.md Normal file
View File

@ -0,0 +1,219 @@
# How to Run the Blog Editor Application
## Prerequisites
1. **Node.js 18+** installed
2. **PostgreSQL/Supabase** database configured
3. **Auth service** running (at `G:\LivingAi\GITTEA_RPO\auth`)
4. **AWS S3** configured (for image uploads)
## Step-by-Step Setup
### 1. Start the Auth Service (Required First)
The blog editor depends on your existing auth service. Make sure it's running:
```bash
cd G:\LivingAi\GITTEA_RPO\auth
npm install # If not already done
npm start # or npm run dev
```
The auth service should be running on `http://localhost:3000` (or your configured port).
### 2. Setup Backend
#### Install Dependencies
```bash
cd blog-editor/backend
npm install
```
#### Configure Environment
Make sure you have a `.env` file in `blog-editor/backend/`:
```bash
# If you haven't created it yet
cp env.example .env
# Then edit .env with your actual values
```
Your `.env` should have:
- `DATABASE_URL` - Your Supabase connection string
- `AUTH_SERVICE_URL` - URL of auth service (default: http://localhost:3000)
- AWS credentials for S3
- Other required variables
#### Run Database Migrations
```bash
npm run migrate
```
This will create the `posts` table and indexes in your Supabase database.
#### Start Backend Server
```bash
npm run dev
```
The backend will start on `http://localhost:5001` (or your configured PORT).
You should see:
```
Server running on port 5001
```
### 3. Setup Frontend
#### Install Dependencies
```bash
cd blog-editor/frontend
npm install
```
#### Configure Environment
Make sure you have a `.env` file in `blog-editor/frontend/`:
```bash
# If you haven't created it yet
cp env.example .env
# Then edit .env with your actual values
```
Your `.env` should have:
- `VITE_API_URL=http://localhost:5001` - Backend API URL
- `VITE_AUTH_API_URL=http://localhost:3000` - Auth service URL
#### Start Frontend Dev Server
```bash
npm run dev
```
The frontend will start on `http://localhost:4000`.
You should see:
```
VITE v5.x.x ready in xxx ms
➜ Local: http://localhost:4000/
➜ Network: use --host to expose
```
## Running Everything Together
### Option 1: Separate Terminals (Recommended)
**Terminal 1 - Auth Service:**
```bash
cd G:\LivingAi\GITTEA_RPO\auth
npm start
```
**Terminal 2 - Blog Editor Backend:**
```bash
cd blog-editor/backend
npm run dev
```
**Terminal 3 - Blog Editor Frontend:**
```bash
cd blog-editor/frontend
npm run dev
```
### Option 2: Using npm scripts (if you create them)
You could create a root `package.json` with scripts to run everything, but separate terminals are easier for debugging.
## Verify Everything is Working
### 1. Check Auth Service
```bash
curl http://localhost:3000/health
# Should return: {"ok":true}
```
### 2. Check Backend
```bash
curl http://localhost:5000/api/health
# Should return: {"status":"ok"}
```
### 3. Check Database Connection
```bash
curl http://localhost:5000/api/test-db
# Should return database connection info
```
### 4. Open Frontend
Open your browser to the frontend URL (usually `http://localhost:5173` or `http://localhost:3000`)
## First Time Usage
1. **Open the frontend** in your browser
2. **Click Login** (or navigate to `/login`)
3. **Enter your phone number** (e.g., `+919876543210` or `9876543210`)
4. **Request OTP** - You'll receive an OTP via SMS (or console if using test numbers)
5. **Enter OTP** to verify
6. **You'll be logged in** and redirected to the dashboard
7. **Create your first post** by clicking "New Post"
## Troubleshooting
### Backend won't start
- Check if port 5001 is already in use
- Verify `.env` file exists and has correct values
- Check database connection string is correct
- Ensure auth service is running
### Frontend won't start
- Check if port is already in use (Vite will auto-select another port)
- Verify `.env` file exists with `VITE_` prefixed variables
- Check that backend is running
### Database connection errors
- Verify Supabase connection string is correct
- Check that password doesn't have special characters that need URL encoding
- Ensure Supabase database is accessible
- Check IP whitelist in Supabase settings
### Auth service connection errors
- Verify auth service is running on the correct port
- Check `AUTH_SERVICE_URL` in backend `.env`
- Check `VITE_AUTH_API_URL` in frontend `.env`
### CORS errors
- Verify `CORS_ORIGIN` in backend `.env` matches frontend URL
- Check that auth service CORS settings allow your frontend origin
## Production Build
### Build Frontend
```bash
cd blog-editor/frontend
npm run build
```
The built files will be in `blog-editor/frontend/dist/`
### Start Backend in Production
```bash
cd blog-editor/backend
NODE_ENV=production npm start
```
## Quick Commands Reference
```bash
# Backend
cd blog-editor/backend
npm install # Install dependencies
npm run migrate # Run database migrations
npm run dev # Start dev server
npm start # Start production server
# Frontend
cd blog-editor/frontend
npm install # Install dependencies
npm run dev # Start dev server
npm run build # Build for production
npm run preview # Preview production build
```

123
S3_CORS_SETUP.md Normal file
View File

@ -0,0 +1,123 @@
# S3 CORS Configuration Guide
## Problem
If you're getting "Failed to fetch" error when uploading images, it's likely a CORS (Cross-Origin Resource Sharing) issue with your S3 bucket.
## Solution: Configure S3 Bucket CORS
### Step 1: Go to AWS S3 Console
1. Log in to AWS Console
2. Navigate to S3
3. Click on your bucket (e.g., `livingai-media-bucket`)
### Step 2: Configure CORS
1. Click on the **Permissions** tab
2. Scroll down to **Cross-origin resource sharing (CORS)**
3. Click **Edit**
### Step 3: Add CORS Configuration
Paste this CORS configuration:
```json
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"GET",
"PUT",
"POST",
"HEAD"
],
"AllowedOrigins": [
"http://localhost:4000",
"http://localhost:3000",
"http://localhost:5173",
"https://your-production-domain.com"
],
"ExposeHeaders": [
"ETag"
],
"MaxAgeSeconds": 3000
}
]
```
**Important:**
- Replace `https://your-production-domain.com` with your actual production domain
- Add any other origins you need (e.g., staging domains)
### Step 4: Save Configuration
1. Click **Save changes**
2. Wait a few seconds for the changes to propagate
### Step 5: Test Again
Try uploading an image again. The CORS error should be resolved.
## Alternative: Bucket Policy (if CORS doesn't work)
If CORS still doesn't work, you may also need to configure the bucket policy:
1. Go to **Permissions** tab
2. Click **Bucket policy**
3. Add this policy (replace `YOUR-BUCKET-NAME` and `YOUR-ACCOUNT-ID`):
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
},
{
"Sid": "AllowPutObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
}
]
}
```
**Note:** This makes your bucket publicly writable. For production, use IAM roles or signed URLs (which you're already using).
## Verify CORS is Working
After configuring CORS, check the browser console. You should see:
- No CORS errors
- Successful PUT request to S3
- Image uploads working
## Common Issues
### Issue 1: CORS still not working
- **Solution:** Clear browser cache and try again
- **Solution:** Make sure the origin in CORS matches exactly (including http vs https, port numbers)
### Issue 2: "Access Denied" error
- **Solution:** Check IAM permissions for your AWS credentials
- **Solution:** Ensure your AWS user has `s3:PutObject` permission
### Issue 3: Presigned URL expires
- **Solution:** The presigned URL expires in 3600 seconds (1 hour). If you wait too long, generate a new one.
## Testing CORS Configuration
You can test if CORS is configured correctly using curl:
```bash
curl -X OPTIONS \
-H "Origin: http://localhost:4000" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Content-Type" \
https://YOUR-BUCKET-NAME.s3.REGION.amazonaws.com/images/test.jpg \
-v
```
You should see `Access-Control-Allow-Origin` in the response headers.

104
SUPABASE_SETUP.md Normal file
View File

@ -0,0 +1,104 @@
# Supabase Database Setup
The blog editor uses Supabase PostgreSQL for storing blog posts. The auth service uses its own separate database.
## Connection String Format
Your Supabase connection string should look like:
```
postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres
```
## Setup Steps
### 1. Get Your Supabase Connection String
1. Go to your Supabase project dashboard
2. Navigate to **Settings** → **Database**
3. Find the **Connection string** section
4. Copy the **Connection pooling** connection string (recommended)
5. Replace `[YOUR-PASSWORD]` with your actual database password
### 2. Update Backend `.env`
Add to `blog-editor/backend/.env`:
```env
DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:your_actual_password@aws-1-ap-south-1.pooler.supabase.com:5432/postgres
```
### 3. Create Database Schema
Run the migrations to create the required tables:
```bash
cd blog-editor/backend
npm run migrate
```
This will create:
- `users` table (if not exists - though auth service has its own users table)
- `posts` table for blog posts
- Required indexes
### 4. Verify Connection
Test the database connection:
```bash
# The backend has a test endpoint
curl http://localhost:5001/api/test-db
```
## Database Schema
The blog editor creates these tables in Supabase:
### `posts` table
- `id` (UUID, Primary Key)
- `user_id` (UUID, Foreign Key - references auth service user ID)
- `title` (VARCHAR)
- `content_json` (JSONB) - TipTap editor content
- `slug` (VARCHAR, Unique)
- `status` (VARCHAR: 'draft' or 'published')
- `created_at` (TIMESTAMP)
- `updated_at` (TIMESTAMP)
### Indexes
- `idx_posts_user_id` - For fast user queries
- `idx_posts_slug` - For fast slug lookups
- `idx_posts_status` - For filtering by status
## Important Notes
1. **Separate Databases**:
- Blog editor uses Supabase PostgreSQL
- Auth service uses its own separate database
- User IDs from auth service are stored as `user_id` in posts table
2. **Connection Pooling**:
- Supabase connection string uses their pooler
- This is more efficient for serverless/server applications
- SSL is automatically handled
3. **User IDs**:
- The `user_id` in posts table references the user ID from your auth service
- Make sure the auth service user IDs are UUIDs (which they should be)
4. **Database Name**:
- Default Supabase database is `postgres`
- You can create a separate database if needed, just update the connection string
## Troubleshooting
### Connection Issues
- Verify your password is correct
- Check that your IP is allowed in Supabase (Settings → Database → Connection Pooling)
- Ensure you're using the connection pooling URL (not direct connection)
### Migration Issues
- Make sure you have proper permissions on the database
- Check that the database exists
- Verify the connection string format is correct
### SSL Issues
- Supabase requires SSL connections
- The code automatically sets `rejectUnauthorized: false` for Supabase
- This is safe because Supabase uses valid SSL certificates

117
TROUBLESHOOTING.md Normal file
View File

@ -0,0 +1,117 @@
# Troubleshooting Guide
## "Failed to fetch" Error When Uploading Images
This error means the frontend cannot connect to the backend API. Check the following:
### 1. Check Backend is Running
Make sure your backend server is running:
```bash
cd blog-editor/backend
npm run dev
```
You should see:
```
✅ Blog Editor Backend is running!
🌐 Server: http://localhost:5001
```
### 2. Check Frontend API URL
In `blog-editor/frontend/.env`, make sure:
```env
VITE_API_URL=http://localhost:5001
```
**Important:** The port must match your backend port (check your backend terminal output).
### 3. Check Browser Console
Open browser DevTools (F12) → Console tab and look for:
- Network errors
- CORS errors
- 404 errors
- Connection refused errors
### 4. Test Backend Manually
Open in browser or use curl:
```bash
# Health check
curl http://localhost:5001/api/health
# Should return: {"status":"ok"}
```
### 5. Check CORS Configuration
In `blog-editor/backend/.env`:
```env
CORS_ORIGIN=http://localhost:4000
```
Make sure this matches your frontend URL.
### 6. Check AWS S3 Configuration
If you see "AWS S3 is not configured" error:
In `blog-editor/backend/.env`, add:
```env
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
S3_BUCKET_NAME=blog-editor-images
```
**Note:** Image uploads won't work without AWS S3 configured. You can:
- Set up AWS S3 (recommended for production)
- Or temporarily disable image uploads for testing
### 7. Check Authentication Token
Make sure you're logged in. The upload endpoint requires authentication.
Check browser console → Application → Local Storage:
- Should have `access_token`
- Should have `refresh_token`
### 8. Common Issues
**Issue:** Backend on different port
- **Fix:** Update `VITE_API_URL` in frontend `.env` to match backend port
**Issue:** CORS blocking requests
- **Fix:** Update `CORS_ORIGIN` in backend `.env` to match frontend URL
**Issue:** Backend not running
- **Fix:** Start backend: `cd blog-editor/backend && npm run dev`
**Issue:** Network error
- **Fix:** Check firewall, VPN, or proxy settings
### 9. Test Upload Endpoint Directly
```bash
# Get your access token from browser localStorage
# Then test:
curl -X POST http://localhost:5001/api/upload/presigned-url \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename":"test.jpg","contentType":"image/jpeg"}'
```
### 10. Enable Detailed Logging
Check backend terminal for error messages when you try to upload.
## Quick Fix Checklist
- [ ] Backend is running (check terminal)
- [ ] Frontend `.env` has correct `VITE_API_URL`
- [ ] Backend `.env` has correct `CORS_ORIGIN`
- [ ] You're logged in (check localStorage for tokens)
- [ ] Browser console shows no CORS errors
- [ ] AWS S3 is configured (if using image uploads)

9
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
.env
*.log
.DS_Store
dist/
build/
# TEMPORARY: Local images folder (remove in production)
images/*
!images/.gitkeep

39
backend/README.md Normal file
View File

@ -0,0 +1,39 @@
# Blog Editor Backend
Express.js API server for the blog editor application.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create `.env` file (see `.env.example`)
3. Create PostgreSQL database:
```bash
createdb blog_editor
```
4. Run migrations:
```bash
npm run migrate
```
5. Start server:
```bash
npm run dev
```
## API Documentation
See main README.md for API endpoints.
## Database
PostgreSQL database with two main tables:
- `users` - User accounts
- `posts` - Blog posts
Run migrations to set up the schema.

38
backend/UPDATE_ENV.md Normal file
View File

@ -0,0 +1,38 @@
# Update Your .env File
Your backend is running but showing old port values. Update your `.env` file in `blog-editor/backend/`:
## Required Changes
Change these values in your `.env` file:
```env
# Change from 3200 (or whatever you have) to:
PORT=5001
# Change from http://localhost:3000 to:
CORS_ORIGIN=http://localhost:4000
# Keep auth service URL as is (it's correct):
AUTH_SERVICE_URL=http://localhost:3000
```
## Complete .env Example
```env
PORT=5001
NODE_ENV=development
DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres
AUTH_SERVICE_URL=http://localhost:3000
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_aws_access_key_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
S3_BUCKET_NAME=blog-editor-images
CORS_ORIGIN=http://localhost:4000
```
After updating, restart your backend server.

View File

@ -0,0 +1,40 @@
import pkg from 'pg'
import dotenv from 'dotenv'
dotenv.config()
const { Pool } = pkg
// Support both connection string (Supabase) and individual parameters
let poolConfig
if (process.env.DATABASE_URL) {
// Use connection string (Supabase format)
poolConfig = {
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: false // Supabase requires SSL
},
// Connection pool settings for Supabase
max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}
} else {
// Use individual parameters (local development)
poolConfig = {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'blog_editor',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
}
}
export const pool = new Pool(poolConfig)
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err)
process.exit(-1)
})

84
backend/config/s3.js Normal file
View File

@ -0,0 +1,84 @@
import { S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { PutObjectCommand, HeadBucketCommand } from '@aws-sdk/client-s3'
import { v4 as uuid } from 'uuid'
import dotenv from 'dotenv'
import logger from '../utils/logger.js'
dotenv.config()
// Check if S3 is configured (support both S3_BUCKET_NAME and AWS_BUCKET_NAME for compatibility)
export const isS3Configured = () => {
return !!(
process.env.AWS_ACCESS_KEY_ID &&
process.env.AWS_SECRET_ACCESS_KEY &&
(process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME)
)
}
// Get bucket name (support both env var names)
export const BUCKET_NAME = process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME
// Only create S3 client if credentials are available
export const s3Client = isS3Configured()
? new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
})
: null
// Export HeadBucketCommand for health checks
export { HeadBucketCommand }
export async function getPresignedUploadUrl(filename, contentType) {
logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType })
if (!isS3Configured()) {
logger.error('S3', 'S3 not configured', null)
throw new Error('AWS S3 is not configured. Please set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and S3_BUCKET_NAME (or AWS_BUCKET_NAME) in .env file.')
}
if (!s3Client) {
logger.error('S3', 'S3 client not initialized', null)
throw new Error('S3 client is not initialized. Check your AWS credentials.')
}
if (!BUCKET_NAME) {
logger.error('S3', 'Bucket name not configured', null)
throw new Error('S3 bucket name is not configured. Please set S3_BUCKET_NAME or AWS_BUCKET_NAME in .env file.')
}
// Extract file extension from filename or content type
const ext = filename.split('.').pop() || contentType.split('/')[1] || 'jpg'
// Use UUID for unique file names (matching api-v1 pattern)
const key = `images/${uuid()}.${ext}`
logger.s3('GENERATING_PRESIGNED_URL', {
bucket: BUCKET_NAME,
key,
contentType
})
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
})
const startTime = Date.now()
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
const duration = Date.now() - startTime
const imageUrl = `https://${BUCKET_NAME}.s3.${process.env.AWS_REGION || 'us-east-1'}.amazonaws.com/${key}`
logger.s3('PRESIGNED_URL_CREATED', {
key,
bucket: BUCKET_NAME,
duration: `${duration}ms`,
expiresIn: '3600s'
})
return { uploadUrl, imageUrl, key }
}

74
backend/env.example Normal file
View File

@ -0,0 +1,74 @@
# =====================================================
# BLOG EDITOR BACKEND - ENVIRONMENT CONFIGURATION
# =====================================================
# Copy this file to .env and update with your actual values
# DO NOT commit .env file to git (it's in .gitignore)
# =====================================================
# =====================================================
# SERVER CONFIGURATION
# =====================================================
PORT=5001
NODE_ENV=development
# =====================================================
# DATABASE CONFIGURATION (PostgreSQL)
# =====================================================
# Option 1: Use Supabase connection string (recommended)
# Format: postgresql://user:password@host:port/database
DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres
# Option 2: Use individual parameters (for local development)
# Uncomment and use these if not using DATABASE_URL
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=blog_editor
# DB_USER=postgres
# DB_PASSWORD=your_database_password_here
# =====================================================
# AUTH SERVICE INTEGRATION
# =====================================================
# URL of your existing auth service
# The blog editor validates JWT tokens via this service
AUTH_SERVICE_URL=http://localhost:3000
# =====================================================
# AWS S3 CONFIGURATION (for image uploads)
# =====================================================
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_aws_access_key_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
# Use either S3_BUCKET_NAME or AWS_BUCKET_NAME (both work)
S3_BUCKET_NAME=blog-editor-images
# AWS_BUCKET_NAME=blog-editor-images # Alternative (matches api-v1 pattern)
# =====================================================
# CORS CONFIGURATION
# =====================================================
# Frontend URL that will make requests to this backend
CORS_ORIGIN=http://localhost:4000
# Production example:
# CORS_ORIGIN=https://your-frontend-domain.com
# =====================================================
# NOTES
# =====================================================
# 1. DATABASE_URL (Supabase):
# - Replace [YOUR-PASSWORD] with your actual Supabase password
# - Supabase connection string includes SSL automatically
# - Make sure to create the 'blog_editor' database in Supabase
# OR use the default 'postgres' database and update connection string
#
# 2. Run migrations to create tables:
# npm run migrate
#
# 3. Ensure your auth service is running on the port specified in AUTH_SERVICE_URL
# Note: Auth service uses its own separate database
#
# 4. For AWS S3:
# - Create an S3 bucket
# - Configure CORS on the bucket to allow PUT requests from your frontend
# - Create an IAM user with s3:PutObject and s3:GetObject permissions
# - Use the IAM user's access key and secret key above

2
backend/images/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# This file keeps the images directory in git
# Images uploaded here are temporary for testing only

100
backend/middleware/auth.js Normal file
View File

@ -0,0 +1,100 @@
import axios from 'axios'
import logger from '../utils/logger.js'
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:3000'
/**
* Auth middleware that validates JWT tokens using the existing auth service
* Calls /auth/validate-token endpoint to verify tokens
*/
export const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: null
if (!token) {
logger.auth('TOKEN_MISSING', { path: req.path, method: req.method })
return res.status(401).json({ message: 'Access token required' })
}
logger.auth('TOKEN_VALIDATION_START', {
path: req.path,
method: req.method,
tokenPrefix: token.substring(0, 10) + '...'
})
// Validate token with auth service
try {
const startTime = Date.now()
const response = await axios.post(
`${AUTH_SERVICE_URL}/auth/validate-token`,
{ token },
{
headers: {
'Content-Type': 'application/json',
},
timeout: 5000, // 5 second timeout
}
)
const duration = Date.now() - startTime
if (!response.data.valid) {
logger.auth('TOKEN_INVALID', {
path: req.path,
error: response.data.error,
duration: `${duration}ms`
})
return res.status(401).json({
message: response.data.error || 'Invalid token'
})
}
// Extract user info from validated token payload
// The auth service returns payload with user info
const payload = response.data.payload || {}
req.user = {
id: payload.sub, // sub is the user ID in JWT
phone_number: payload.phone_number,
role: payload.role || 'user',
user_type: payload.user_type,
is_guest: payload.is_guest || false,
}
logger.auth('TOKEN_VALIDATED', {
userId: req.user.id,
phone: req.user.phone_number,
role: req.user.role,
duration: `${duration}ms`
})
next()
} catch (error) {
// If auth service is unavailable, return error
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
logger.error('AUTH', 'Auth service unavailable', error)
return res.status(503).json({
message: 'Authentication service unavailable'
})
}
// If auth service returns error, forward it
if (error.response) {
logger.auth('TOKEN_VALIDATION_FAILED', {
status: error.response.status,
error: error.response.data?.error
})
return res.status(error.response.status).json({
message: error.response.data?.error || 'Token validation failed'
})
}
throw error
}
} catch (error) {
logger.error('AUTH', 'Auth middleware error', error)
return res.status(500).json({ message: 'Authentication error' })
}
}

View File

@ -0,0 +1,70 @@
import { pool } from '../config/database.js'
import dotenv from 'dotenv'
dotenv.config()
async function migrate() {
try {
console.log('Running migrations...')
// Note: Users table is managed by the auth service in a separate database
// We only store user_id (UUID) references here, no foreign key constraint
// Create posts table
await pool.query(`
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
title VARCHAR(500) NOT NULL,
content_json JSONB NOT NULL,
slug VARCHAR(500) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`)
console.log('✓ Posts table created')
console.log(' Note: user_id references users from auth service (separate database)')
// Create indexes
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id)
`)
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)
`)
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status)
`)
console.log('✓ Indexes created')
// Create function to update updated_at timestamp
await pool.query(`
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
`)
// Create trigger
await pool.query(`
DROP TRIGGER IF EXISTS update_posts_updated_at ON posts;
CREATE TRIGGER update_posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
`)
console.log('✓ Triggers created')
console.log('Migration completed successfully!')
process.exit(0)
} catch (error) {
console.error('Migration failed:', error)
process.exit(1)
}
}
migrate()

27
backend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "blog-editor-backend",
"version": "1.0.0",
"description": "Blog Editor Backend API",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"migrate": "node migrations/migrate.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
"axios": "^1.6.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"multer": "^2.0.2",
"pg": "^8.11.3",
"slugify": "^1.6.6",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

239
backend/routes/posts.js Normal file
View File

@ -0,0 +1,239 @@
import express from 'express'
import { pool } from '../config/database.js'
import slugify from 'slugify'
import logger from '../utils/logger.js'
const router = express.Router()
// Get all posts for current user
// Note: authenticateToken middleware is applied at server level, so req.user is available
router.get('/', async (req, res) => {
try {
logger.transaction('FETCH_POSTS', { userId: req.user.id })
const query = 'SELECT id, title, slug, status, created_at, updated_at FROM posts WHERE user_id = $1 ORDER BY updated_at DESC'
logger.db('SELECT', query, [req.user.id])
const result = await pool.query(query, [req.user.id])
logger.transaction('FETCH_POSTS_SUCCESS', {
userId: req.user.id,
count: result.rows.length
})
res.json(result.rows)
} catch (error) {
logger.error('POSTS', 'Error fetching posts', error)
res.status(500).json({ message: 'Failed to fetch posts', error: error.message })
}
})
// Get single post by ID
router.get('/:id', async (req, res) => {
try {
logger.transaction('FETCH_POST_BY_ID', {
postId: req.params.id,
userId: req.user.id
})
const query = 'SELECT * FROM posts WHERE id = $1 AND user_id = $2'
logger.db('SELECT', query, [req.params.id, req.user.id])
const result = await pool.query(query, [req.params.id, req.user.id])
if (result.rows.length === 0) {
logger.warn('POSTS', 'Post not found', {
postId: req.params.id,
userId: req.user.id
})
return res.status(404).json({ message: 'Post not found' })
}
logger.transaction('FETCH_POST_BY_ID_SUCCESS', {
postId: req.params.id,
userId: req.user.id
})
res.json(result.rows[0])
} catch (error) {
logger.error('POSTS', 'Error fetching post', error)
res.status(500).json({ message: 'Failed to fetch post', error: error.message })
}
})
// Get post by slug (public)
router.get('/slug/:slug', async (req, res) => {
try {
logger.transaction('FETCH_POST_BY_SLUG', { slug: req.params.slug })
const query = 'SELECT * FROM posts WHERE slug = $1 AND status = $2'
logger.db('SELECT', query, [req.params.slug, 'published'])
const result = await pool.query(query, [req.params.slug, 'published'])
if (result.rows.length === 0) {
logger.warn('POSTS', 'Post not found by slug', { slug: req.params.slug })
return res.status(404).json({ message: 'Post not found' })
}
logger.transaction('FETCH_POST_BY_SLUG_SUCCESS', {
slug: req.params.slug,
postId: result.rows[0].id
})
res.json(result.rows[0])
} catch (error) {
logger.error('POSTS', 'Error fetching post by slug', error)
res.status(500).json({ message: 'Failed to fetch post', error: error.message })
}
})
// Create post
router.post('/', async (req, res) => {
try {
const { title, content_json, status } = req.body
logger.transaction('CREATE_POST', {
userId: req.user.id,
title: title?.substring(0, 50),
status: status || 'draft'
})
if (!title || !content_json) {
logger.warn('POSTS', 'Missing required fields', {
hasTitle: !!title,
hasContent: !!content_json
})
return res.status(400).json({ message: 'Title and content are required' })
}
const slug = slugify(title, { lower: true, strict: true }) + '-' + Date.now()
const postStatus = status || 'draft'
const query = `INSERT INTO posts (user_id, title, content_json, slug, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`
logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus])
const result = await pool.query(query, [
req.user.id,
title,
JSON.stringify(content_json),
slug,
postStatus
])
logger.transaction('CREATE_POST_SUCCESS', {
postId: result.rows[0].id,
userId: req.user.id,
slug: result.rows[0].slug
})
res.status(201).json(result.rows[0])
} catch (error) {
logger.error('POSTS', 'Error creating post', error)
res.status(500).json({ message: 'Failed to create post', error: error.message })
}
})
// Update post
router.put('/:id', async (req, res) => {
try {
const { title, content_json, status } = req.body
logger.transaction('UPDATE_POST', {
postId: req.params.id,
userId: req.user.id,
updates: {
title: title !== undefined,
content: content_json !== undefined,
status: status !== undefined
}
})
// Check if post exists and belongs to user
const checkQuery = 'SELECT id FROM posts WHERE id = $1 AND user_id = $2'
logger.db('SELECT', checkQuery, [req.params.id, req.user.id])
const existingPost = await pool.query(checkQuery, [req.params.id, req.user.id])
if (existingPost.rows.length === 0) {
logger.warn('POSTS', 'Post not found for update', {
postId: req.params.id,
userId: req.user.id
})
return res.status(404).json({ message: 'Post not found' })
}
// Build update query dynamically
const updates = []
const values = []
let paramCount = 1
if (title !== undefined) {
updates.push(`title = $${paramCount++}`)
values.push(title)
}
if (content_json !== undefined) {
updates.push(`content_json = $${paramCount++}`)
values.push(JSON.stringify(content_json))
}
if (status !== undefined) {
updates.push(`status = $${paramCount++}`)
values.push(status)
}
// Update slug if title changed
if (title !== undefined) {
const slug = slugify(title, { lower: true, strict: true }) + '-' + Date.now()
updates.push(`slug = $${paramCount++}`)
values.push(slug)
}
updates.push(`updated_at = NOW()`)
values.push(req.params.id, req.user.id)
const updateQuery = `UPDATE posts SET ${updates.join(', ')} WHERE id = $${paramCount} AND user_id = $${paramCount + 1} RETURNING *`
logger.db('UPDATE', updateQuery, values)
const result = await pool.query(updateQuery, values)
logger.transaction('UPDATE_POST_SUCCESS', {
postId: req.params.id,
userId: req.user.id
})
res.json(result.rows[0])
} catch (error) {
logger.error('POSTS', 'Error updating post', error)
res.status(500).json({ message: 'Failed to update post', error: error.message })
}
})
// Delete post
router.delete('/:id', async (req, res) => {
try {
logger.transaction('DELETE_POST', {
postId: req.params.id,
userId: req.user.id
})
const query = 'DELETE FROM posts WHERE id = $1 AND user_id = $2 RETURNING id'
logger.db('DELETE', query, [req.params.id, req.user.id])
const result = await pool.query(query, [req.params.id, req.user.id])
if (result.rows.length === 0) {
logger.warn('POSTS', 'Post not found for deletion', {
postId: req.params.id,
userId: req.user.id
})
return res.status(404).json({ message: 'Post not found' })
}
logger.transaction('DELETE_POST_SUCCESS', {
postId: req.params.id,
userId: req.user.id
})
res.json({ message: 'Post deleted successfully' })
} catch (error) {
logger.error('POSTS', 'Error deleting post', error)
res.status(500).json({ message: 'Failed to delete post', error: error.message })
}
})
export default router

169
backend/routes/upload.js Normal file
View File

@ -0,0 +1,169 @@
import express from 'express'
import multer from 'multer'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
import { getPresignedUploadUrl } from '../config/s3.js'
import logger from '../utils/logger.js'
import { v4 as uuid } from 'uuid'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const router = express.Router()
// Configure multer for local file storage (TEMPORARY - FOR TESTING ONLY)
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '..', 'images')
// Ensure directory exists
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
cb(null, uploadDir)
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname)
const filename = `${uuid()}${ext}`
cb(null, filename)
}
})
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB limit
},
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true)
} else {
cb(new Error('Only image files are allowed'), false)
}
}
})
// Get presigned URL for image upload
// Note: authenticateToken middleware is applied at server level
router.post('/presigned-url', async (req, res) => {
try {
const { filename, contentType } = req.body
logger.transaction('GENERATE_PRESIGNED_URL', {
userId: req.user.id,
filename,
contentType
})
if (!filename || !contentType) {
logger.warn('UPLOAD', 'Missing required fields', {
hasFilename: !!filename,
hasContentType: !!contentType
})
return res.status(400).json({ message: 'Filename and content type are required' })
}
// Validate content type
if (!contentType.startsWith('image/')) {
logger.warn('UPLOAD', 'Invalid content type', { contentType })
return res.status(400).json({ message: 'File must be an image' })
}
// Check if AWS credentials are configured
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
logger.error('UPLOAD', 'AWS credentials not configured', null)
return res.status(500).json({
message: 'AWS S3 is not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in backend .env file.'
})
}
// Support both S3_BUCKET_NAME and AWS_BUCKET_NAME (for compatibility with api-v1)
const bucketName = process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME
if (!bucketName) {
logger.error('UPLOAD', 'S3 bucket name not configured', null)
return res.status(500).json({
message: 'S3 bucket name is not configured. Please set S3_BUCKET_NAME or AWS_BUCKET_NAME in backend .env file.'
})
}
const startTime = Date.now()
const { uploadUrl, imageUrl, key } = await getPresignedUploadUrl(filename, contentType)
const duration = Date.now() - startTime
logger.s3('PRESIGNED_URL_GENERATED', {
userId: req.user.id,
filename,
key,
duration: `${duration}ms`
})
logger.transaction('GENERATE_PRESIGNED_URL_SUCCESS', {
userId: req.user.id,
key,
duration: `${duration}ms`
})
res.json({
uploadUrl,
imageUrl,
})
} catch (error) {
logger.error('UPLOAD', 'Error generating presigned URL', error)
// Provide more specific error messages
let errorMessage = 'Failed to generate upload URL'
if (error.name === 'InvalidAccessKeyId' || error.name === 'SignatureDoesNotMatch') {
errorMessage = 'Invalid AWS credentials. Please check your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.'
} else if (error.name === 'NoSuchBucket') {
const bucketName = process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME
errorMessage = `S3 bucket '${bucketName}' does not exist. Please create it or update S3_BUCKET_NAME/AWS_BUCKET_NAME.`
} else if (error.message) {
errorMessage = error.message
}
res.status(500).json({
message: errorMessage,
error: error.name || 'Unknown error'
})
}
})
// TEMPORARY: Local file upload endpoint (FOR TESTING ONLY - REMOVE IN PRODUCTION)
router.post('/local', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
logger.warn('UPLOAD', 'No file uploaded', null)
return res.status(400).json({ message: 'No image file provided' })
}
logger.transaction('LOCAL_IMAGE_UPLOAD', {
userId: req.user.id,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size
})
// Return the image URL (served statically)
const imageUrl = `/api/images/${req.file.filename}`
logger.transaction('LOCAL_IMAGE_UPLOAD_SUCCESS', {
userId: req.user.id,
filename: req.file.filename,
imageUrl
})
res.json({
imageUrl,
filename: req.file.filename,
size: req.file.size
})
} catch (error) {
logger.error('UPLOAD', 'Error uploading local image', error)
res.status(500).json({
message: 'Failed to upload image',
error: error.message
})
}
})
export default router

294
backend/server.js Normal file
View File

@ -0,0 +1,294 @@
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import axios from 'axios'
import path from 'path'
import { fileURLToPath } from 'url'
import { pool } from './config/database.js'
import { authenticateToken } from './middleware/auth.js'
import { s3Client, BUCKET_NAME, HeadBucketCommand, isS3Configured } from './config/s3.js'
import postRoutes from './routes/posts.js'
import uploadRoutes from './routes/upload.js'
import logger from './utils/logger.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
dotenv.config()
const app = express()
const PORT = process.env.PORT || 5001
// Startup logging
console.log('\n🚀 Starting Blog Editor Backend...\n')
console.log('📋 Configuration:')
console.log(` Port: ${PORT}`)
console.log(` Environment: ${process.env.NODE_ENV || 'development'}`)
console.log(` CORS Origin: ${process.env.CORS_ORIGIN || 'http://localhost:4000'}`)
// Middleware - CORS Configuration
const corsOptions = {
origin: function (origin, callback) {
// Allow requests with no origin (mobile apps, Postman, etc.)
if (!origin) {
return callback(null, true)
}
const allowedOrigins = [
'http://localhost:4000',
'http://localhost:3000',
'http://localhost:5173',
process.env.CORS_ORIGIN
].filter(Boolean) // Remove undefined values
if (allowedOrigins.includes(origin)) {
callback(null, true)
} else {
console.warn(`⚠️ CORS: Blocked origin: ${origin}`)
console.warn(` Allowed origins: ${allowedOrigins.join(', ')}`)
callback(new Error('Not allowed by CORS'))
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}
app.use(cors(corsOptions))
// Handle CORS errors
app.use((err, req, res, next) => {
if (err && err.message === 'Not allowed by CORS') {
return res.status(403).json({
error: 'Origin not allowed by CORS',
message: `Origin ${req.headers.origin} is not allowed. Allowed origins: http://localhost:4000, http://localhost:3000, http://localhost:5173`
})
}
next(err)
})
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// Request logging middleware
app.use((req, res, next) => {
const startTime = Date.now()
const originalSend = res.send
res.send = function (body) {
const duration = Date.now() - startTime
const userId = req.user?.id || 'anonymous'
logger.api(req.method, req.path, res.statusCode, duration, userId)
return originalSend.call(this, body)
}
logger.debug('REQUEST', `${req.method} ${req.path}`, {
query: req.query,
body: req.method === 'POST' || req.method === 'PUT' ? '***' : undefined,
ip: req.ip,
userAgent: req.get('user-agent')
})
next()
})
// Routes - Auth is handled by existing auth service
// Blog editor backend validates tokens via auth service
app.use('/api/posts', authenticateToken, postRoutes)
app.use('/api/upload', authenticateToken, uploadRoutes)
// TEMPORARY: Serve static images (FOR TESTING ONLY - REMOVE IN PRODUCTION)
app.use('/api/images', express.static(path.join(__dirname, 'images')))
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' })
})
// Test database connection
app.get('/api/test-db', async (req, res) => {
try {
logger.db('SELECT', 'SELECT NOW()', [])
const result = await pool.query('SELECT NOW()')
logger.info('DATABASE', 'Test query successful', { time: result.rows[0].now })
res.json({ message: 'Database connected', time: result.rows[0].now })
} catch (error) {
logger.error('DATABASE', 'Test query failed', error)
res.status(500).json({ error: 'Database connection failed', message: error.message })
}
})
// General error handling middleware (after CORS error handler)
app.use((err, req, res, next) => {
// Skip if already handled by CORS error handler
if (err && err.message === 'Not allowed by CORS') {
return next(err) // This should have been handled above, but just in case
}
logger.error('SERVER', 'Unhandled error', err)
console.error(err.stack)
res.status(500).json({ message: 'Something went wrong!', error: err.message })
})
// Startup health checks
async function performStartupChecks() {
console.log('\n🔍 Performing startup health checks...\n')
// 1. Check Database Connection
console.log('📊 Checking Database Connection...')
try {
logger.db('SELECT', 'SELECT NOW(), version()', [])
const dbResult = await pool.query('SELECT NOW(), version()')
const dbTime = dbResult.rows[0].now
const dbVersion = dbResult.rows[0].version.split(' ')[0] + ' ' + dbResult.rows[0].version.split(' ')[1]
logger.info('DATABASE', 'Database connection successful', { time: dbTime, version: dbVersion })
console.log(` ✅ Database connected successfully`)
console.log(` 📅 Database time: ${dbTime}`)
console.log(` 🗄️ Database version: ${dbVersion}`)
// Check if posts table exists
logger.db('SELECT', 'Check posts table exists', [])
const tableCheck = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'posts'
)
`)
if (tableCheck.rows[0].exists) {
logger.info('DATABASE', 'Posts table exists', null)
console.log(` ✅ Posts table exists`)
} else {
logger.warn('DATABASE', 'Posts table not found', null)
console.log(` ⚠️ Posts table not found - run 'npm run migrate'`)
}
} catch (error) {
logger.error('DATABASE', 'Database connection failed', error)
console.error(` ❌ Database connection failed: ${error.message}`)
console.error(` 💡 Check your DATABASE_URL in .env file`)
return false
}
// 2. Check AWS S3 Configuration
console.log('\n☁ Checking AWS S3 Configuration...')
try {
if (!isS3Configured()) {
console.log(` ⚠️ AWS S3 not configured`)
console.log(` 💡 Image uploads will not work without AWS S3`)
console.log(` 💡 To enable: Set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and S3_BUCKET_NAME in .env`)
} else {
console.log(` ✅ AWS credentials configured`)
console.log(` 🪣 S3 Bucket: ${BUCKET_NAME}`)
console.log(` 🌍 AWS Region: ${process.env.AWS_REGION || 'us-east-1'}`)
// Try to check bucket access (this might fail if bucket doesn't exist, but that's okay)
if (s3Client) {
try {
await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }))
console.log(` ✅ S3 bucket is accessible`)
} catch (s3Error) {
if (s3Error.name === 'NotFound' || s3Error.$metadata?.httpStatusCode === 404) {
console.log(` ⚠️ S3 bucket '${BUCKET_NAME}' not found`)
console.log(` 💡 Create the bucket in AWS S3 or check the bucket name`)
} else if (s3Error.name === 'Forbidden' || s3Error.$metadata?.httpStatusCode === 403) {
console.log(` ⚠️ S3 bucket access denied`)
console.log(` 💡 Check IAM permissions for bucket: ${BUCKET_NAME}`)
} else {
console.log(` ⚠️ S3 bucket check failed: ${s3Error.message}`)
}
}
}
}
} catch (error) {
console.error(` ❌ AWS S3 check failed: ${error.message}`)
}
// 3. Check Auth Service Connection
console.log('\n🔐 Checking Auth Service Connection...')
const authServiceUrl = process.env.AUTH_SERVICE_URL || 'http://localhost:3000'
try {
logger.auth('HEALTH_CHECK', { url: authServiceUrl })
const startTime = Date.now()
const healthResponse = await axios.get(`${authServiceUrl}/health`, {
timeout: 5000,
})
const duration = Date.now() - startTime
if (healthResponse.data?.ok || healthResponse.status === 200) {
logger.auth('HEALTH_CHECK_SUCCESS', { url: authServiceUrl, duration: `${duration}ms` })
console.log(` ✅ Auth service is reachable`)
console.log(` 🔗 Auth service URL: ${authServiceUrl}`)
} else {
console.log(` ⚠️ Auth service responded but status unclear`)
}
} catch (error) {
if (error.code === 'ECONNREFUSED') {
console.error(` ❌ Auth service connection refused`)
console.error(` 💡 Make sure auth service is running on ${authServiceUrl}`)
console.error(` 💡 Start it with: cd ../auth && npm start`)
} else if (error.code === 'ETIMEDOUT') {
console.error(` ❌ Auth service connection timeout`)
console.error(` 💡 Check if auth service is running and accessible`)
} else {
console.error(` ⚠️ Auth service check failed: ${error.message}`)
console.error(` 💡 Auth service might not be running or URL is incorrect`)
}
}
// 4. Environment Variables Check
console.log('\n📝 Checking Environment Variables...')
const requiredVars = ['DATABASE_URL']
const optionalVars = ['AUTH_SERVICE_URL', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'S3_BUCKET_NAME']
let missingRequired = []
requiredVars.forEach(varName => {
if (!process.env[varName]) {
missingRequired.push(varName)
}
})
if (missingRequired.length > 0) {
console.error(` ❌ Missing required variables: ${missingRequired.join(', ')}`)
console.error(` 💡 Check your .env file`)
return false
} else {
console.log(` ✅ All required environment variables are set`)
}
const missingOptional = optionalVars.filter(varName => !process.env[varName])
if (missingOptional.length > 0) {
console.log(` ⚠️ Optional variables not set: ${missingOptional.join(', ')}`)
console.log(` 💡 Some features may not work without these`)
}
console.log('\n✅ Startup checks completed!\n')
return true
}
// Start server with health checks
async function startServer() {
const checksPassed = await performStartupChecks()
if (!checksPassed) {
console.error('\n❌ Startup checks failed. Please fix the issues above.\n')
process.exit(1)
}
app.listen(PORT, () => {
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log(`✅ Blog Editor Backend is running!`)
console.log(` 🌐 Server: http://localhost:${PORT}`)
console.log(` 💚 Health: http://localhost:${PORT}/api/health`)
console.log(` 🗄️ DB Test: http://localhost:${PORT}/api/test-db`)
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n')
})
}
startServer().catch((error) => {
console.error('❌ Failed to start server:', error)
process.exit(1)
})
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM signal received: closing HTTP server')
await pool.end()
process.exit(0)
})

80
backend/utils/logger.js Normal file
View File

@ -0,0 +1,80 @@
/**
* Simple logging utility for the blog editor backend
* Provides consistent log formatting with timestamps
*/
const getTimestamp = () => {
return new Date().toISOString()
}
const formatLog = (level, category, message, data = null) => {
const timestamp = getTimestamp()
const logEntry = {
timestamp,
level,
category,
message,
...(data && { data })
}
return JSON.stringify(logEntry)
}
export const logger = {
info: (category, message, data = null) => {
console.log(`[INFO] ${formatLog('INFO', category, message, data)}`)
},
error: (category, message, error = null) => {
const errorData = error ? {
message: error.message,
stack: error.stack,
name: error.name,
...(error.response && { response: { status: error.response.status, data: error.response.data } })
} : null
console.error(`[ERROR] ${formatLog('ERROR', category, message, errorData)}`)
},
warn: (category, message, data = null) => {
console.warn(`[WARN] ${formatLog('WARN', category, message, data)}`)
},
debug: (category, message, data = null) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[DEBUG] ${formatLog('DEBUG', category, message, data)}`)
}
},
// Transaction-specific logging
transaction: (operation, details) => {
logger.info('TRANSACTION', `${operation}`, details)
},
// Database-specific logging
db: (operation, query, params = null) => {
logger.debug('DATABASE', `${operation}`, {
query: query.substring(0, 100) + (query.length > 100 ? '...' : ''),
params: params ? (Array.isArray(params) ? `[${params.length} params]` : params) : null
})
},
// API request/response logging
api: (method, path, statusCode, duration = null, userId = null) => {
logger.info('API', `${method} ${path}`, {
statusCode,
...(duration && { duration: `${duration}ms` }),
...(userId && { userId })
})
},
// S3 operation logging
s3: (operation, details) => {
logger.info('S3', `${operation}`, details)
},
// Auth operation logging
auth: (operation, details) => {
logger.info('AUTH', `${operation}`, details)
}
}
export default logger

24
frontend/.gitignore vendored Normal file
View 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?

34
frontend/README.md Normal file
View File

@ -0,0 +1,34 @@
# Blog Editor Frontend
React + Vite frontend for the blog editor application.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create `.env` file:
```env
VITE_API_URL=http://localhost:5000
```
3. Start development server:
```bash
npm run dev
```
## Build
```bash
npm run build
```
## Features
- TipTap rich text editor
- Auto-save functionality
- Image upload to S3
- JWT authentication
- Responsive design

40
frontend/env.example Normal file
View File

@ -0,0 +1,40 @@
# =====================================================
# BLOG EDITOR FRONTEND - ENVIRONMENT CONFIGURATION
# =====================================================
# Copy this file to .env and update with your actual values
# DO NOT commit .env file to git (it's in .gitignore)
# =====================================================
# =====================================================
# BLOG EDITOR BACKEND API URL
# =====================================================
# URL of the blog editor backend API
# This is where posts, uploads, etc. are handled
VITE_API_URL=http://localhost:5001
# Production example:
# VITE_API_URL=https://api.yourdomain.com
# =====================================================
# AUTH SERVICE API URL
# =====================================================
# URL of your existing auth service
# This is where authentication (login, OTP, etc.) is handled
VITE_AUTH_API_URL=http://localhost:3000
# Production example:
# VITE_AUTH_API_URL=https://auth.yourdomain.com
# =====================================================
# NOTES
# =====================================================
# 1. VITE_ prefix is required for Vite to expose these variables
# Access in code: import.meta.env.VITE_API_URL
#
# 2. Make sure both services are running:
# - Auth service on port 3000 (or your configured port)
# - Blog editor backend on port 5001 (or your configured port)
# - Blog editor frontend on port 4000 (or your configured port)
#
# 3. For production builds, these values are baked into the build
# Make sure to set correct production URLs before building

13
frontend/index.html Normal file
View 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>Blog Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

33
frontend/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "blog-editor-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-color": "^2.1.13",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-text-style": "^2.1.13",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^7.3.1"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

56
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,56 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Editor from './pages/Editor'
import BlogPost from './pages/BlogPost'
function PrivateRoute({ children }) {
const { user, loading } = useAuth()
if (loading) {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>
}
return user ? children : <Navigate to="/login" />
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route
path="/editor/:id?"
element={
<PrivateRoute>
<Editor />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
)
}
function App() {
return (
<AuthProvider>
<Router>
<AppRoutes />
<Toaster position="top-right" />
</Router>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,235 @@
import { useEditor, EditorContent } from '@tiptap/react'
import { useRef, useEffect } from 'react'
import StarterKit from '@tiptap/starter-kit'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Underline from '@tiptap/extension-underline'
import { FontSize } from '../extensions/FontSize'
import { ImageResize } from '../extensions/ImageResize'
import Toolbar from './Toolbar'
import api from '../utils/api'
import toast from 'react-hot-toast'
export default function Editor({ content, onChange, onImageUpload }) {
const editorRef = useRef(null)
const handleImageUpload = async (file) => {
const editor = editorRef.current
if (!editor) {
toast.error('Editor not ready')
return
}
try {
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file')
return
}
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('Image size must be less than 10MB')
return
}
toast.loading('Uploading image...', { id: 'image-upload' })
// TEMPORARY: Use local upload for testing (REMOVE IN PRODUCTION)
// TODO: Remove this and use S3 upload instead
let imageUrl
try {
const formData = new FormData()
formData.append('image', file)
console.log('Uploading image locally (TEMPORARY):', {
filename: file.name,
size: file.size,
type: file.type
})
const response = await api.post('/upload/local', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
// Get full URL (backend serves images at /api/images/)
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:5001'
imageUrl = `${baseUrl}${response.data.imageUrl}`
console.log('Local upload successful:', {
imageUrl,
filename: response.data.filename
})
} catch (error) {
console.error('Local upload failed:', error)
if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') {
throw new Error('Cannot connect to server. Make sure the backend is running.')
}
if (error.response?.status === 401) {
throw new Error('Authentication failed. Please login again.')
}
throw new Error(error.response?.data?.message || error.message || 'Failed to upload image')
}
/* ORIGINAL S3 UPLOAD CODE (COMMENTED OUT FOR TESTING)
// Get presigned URL
let data
try {
const response = await api.post('/upload/presigned-url', {
filename: file.name,
contentType: file.type,
})
data = response.data
} catch (error) {
console.error('Presigned URL request failed:', error)
if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') {
throw new Error('Cannot connect to server. Make sure the backend is running.')
}
if (error.response?.status === 401) {
throw new Error('Authentication failed. Please login again.')
}
if (error.response?.status === 500) {
throw new Error('Server error. Check if AWS S3 is configured correctly.')
}
throw error
}
// Upload to S3
console.log('Uploading to S3:', {
uploadUrl: data.uploadUrl.substring(0, 100) + '...',
imageUrl: data.imageUrl,
fileSize: file.size,
contentType: file.type
})
let uploadResponse
try {
uploadResponse = await fetch(data.uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
} catch (fetchError) {
console.error('S3 upload fetch error:', fetchError)
if (fetchError.message === 'Failed to fetch' || fetchError.name === 'TypeError') {
throw new Error('Failed to connect to S3. This might be a CORS issue. Check your S3 bucket CORS configuration.')
}
throw fetchError
}
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text().catch(() => 'Unknown error')
console.error('S3 upload failed:', {
status: uploadResponse.status,
statusText: uploadResponse.statusText,
error: errorText
})
throw new Error(`S3 upload failed: ${uploadResponse.status} ${uploadResponse.statusText}. ${errorText}`)
}
console.log('S3 upload successful:', {
status: uploadResponse.status,
imageUrl: data.imageUrl
})
// Insert image in editor
const imageUrl = data.imageUrl
*/
editor.chain().focus().setImage({
src: imageUrl,
alt: file.name,
}).run()
toast.success('Image uploaded successfully!', { id: 'image-upload' })
if (onImageUpload) {
onImageUpload(imageUrl)
}
} catch (error) {
console.error('Image upload failed:', error)
const errorMessage = error.response?.data?.message ||
error.message ||
'Failed to upload image. Please try again.'
toast.error(errorMessage, {
id: 'image-upload',
duration: 5000,
})
// Log detailed error for debugging
if (error.response) {
console.error('Error response:', error.response.data)
console.error('Error status:', error.response.status)
}
if (error.request) {
console.error('Request made but no response:', error.request)
}
}
}
const editor = useEditor({
extensions: [
StarterKit,
ImageResize,
TextStyle,
Color,
Underline,
FontSize,
],
content: content || '',
onUpdate: ({ editor }) => {
onChange(editor.getJSON())
},
editorProps: {
attributes: {
class: 'ProseMirror',
},
handleDrop: (view, event, slice, moved) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
const file = event.dataTransfer.files[0]
if (file.type.startsWith('image/')) {
event.preventDefault()
handleImageUpload(file)
return true
}
}
return false
},
handlePaste: (view, event, slice) => {
const items = Array.from(event.clipboardData?.items || [])
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault()
const file = item.getAsFile()
if (file) {
handleImageUpload(file)
}
return true
}
}
return false
},
},
})
// Store editor reference
useEffect(() => {
if (editor) {
editorRef.current = editor
}
}, [editor])
if (!editor) {
return null
}
return (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<Toolbar editor={editor} onImageUpload={handleImageUpload} />
<EditorContent editor={editor} className="min-h-[400px] bg-white" />
</div>
)
}

View File

@ -0,0 +1,221 @@
import { useState } from 'react'
const Toolbar = ({ editor, onImageUpload }) => {
const [showColorPicker, setShowColorPicker] = useState(false)
const [showFontSize, setShowFontSize] = useState(false)
if (!editor) {
return null
}
const colors = [
'#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB',
'#EF4444', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6',
]
const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '32px']
const handleImageInput = (e) => {
const file = e.target.files?.[0]
if (file) {
onImageUpload(file)
}
e.target.value = ''
}
return (
<div className="border-b border-gray-300 bg-gray-50 p-2 flex flex-wrap items-center gap-2">
{/* Text Formatting */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('bold') ? 'bg-gray-300' : ''
}`}
title="Bold"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('italic') ? 'bg-gray-300' : ''
}`}
title="Italic"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('underline') ? 'bg-gray-300' : ''
}`}
title="Underline"
>
<u>U</u>
</button>
</div>
{/* Headings */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('heading', { level: 1 }) ? 'bg-gray-300' : ''
}`}
title="Heading 1"
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('heading', { level: 2 }) ? 'bg-gray-300' : ''
}`}
title="Heading 2"
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('heading', { level: 3 }) ? 'bg-gray-300' : ''
}`}
title="Heading 3"
>
H3
</button>
</div>
{/* Lists */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('bulletList') ? 'bg-gray-300' : ''
}`}
title="Bullet List"
>
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('orderedList') ? 'bg-gray-300' : ''
}`}
title="Numbered List"
>
1.
</button>
</div>
{/* Quote & Code */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('blockquote') ? 'bg-gray-300' : ''
}`}
title="Quote"
>
"
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('codeBlock') ? 'bg-gray-300' : ''
}`}
title="Code Block"
>
{'</>'}
</button>
</div>
{/* Color Picker */}
<div className="relative border-r border-gray-300 pr-2">
<button
onClick={() => setShowColorPicker(!showColorPicker)}
className="p-2 rounded hover:bg-gray-200"
title="Text Color"
>
🎨
</button>
{showColorPicker && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded shadow-lg p-2 z-10">
<div className="grid grid-cols-5 gap-1">
{colors.map((color) => (
<button
key={color}
onClick={() => {
editor.chain().focus().setColor(color).run()
setShowColorPicker(false)
}}
className="w-6 h-6 rounded border border-gray-300"
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
)}
</div>
{/* Font Size */}
<div className="relative border-r border-gray-300 pr-2">
<button
onClick={() => setShowFontSize(!showFontSize)}
className="p-2 rounded hover:bg-gray-200"
title="Font Size"
>
Aa
</button>
{showFontSize && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded shadow-lg p-2 z-10">
{fontSizes.map((size) => (
<button
key={size}
onClick={() => {
const sizeValue = parseInt(size.replace('px', ''))
editor.chain().focus().setFontSize(sizeValue).run()
setShowFontSize(false)
}}
className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded text-sm"
style={{ fontSize: size }}
>
{size}
</button>
))}
</div>
)}
</div>
{/* Image Upload */}
<div>
<input
type="file"
accept="image/*"
onChange={handleImageInput}
className="hidden"
id="image-upload-input"
/>
<button
type="button"
onClick={() => {
const input = document.getElementById('image-upload-input')
if (input) {
input.click()
}
}}
className={`p-2 rounded hover:bg-gray-200 ${
editor.isActive('image') ? 'bg-gray-300' : ''
}`}
title="Insert Image"
>
🖼
</button>
</div>
</div>
)
}
export default Toolbar

View File

@ -0,0 +1,135 @@
import { createContext, useContext, useState, useEffect } from 'react'
import authApi from '../utils/authApi'
import api from '../utils/api'
const AuthContext = createContext()
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const accessToken = localStorage.getItem('access_token')
const savedUser = localStorage.getItem('user')
if (accessToken && savedUser) {
setUser(JSON.parse(savedUser))
// Verify token is still valid by validating with auth service
authApi.post('/auth/validate-token', { token: accessToken })
.then((res) => {
if (res.data.valid && res.data.payload) {
const payload = res.data.payload
const userData = {
id: payload.sub,
phone_number: payload.phone_number,
role: payload.role || 'user',
user_type: payload.user_type,
is_guest: payload.is_guest || false,
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} else {
// Token invalid, try refresh
refreshToken()
}
})
.catch(() => {
// Try refresh token
refreshToken()
})
.finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [])
const refreshToken = async () => {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
logout()
return
}
try {
const res = await authApi.post('/auth/refresh', { refresh_token: refreshToken })
const { access_token, refresh_token: newRefreshToken } = res.data
localStorage.setItem('access_token', access_token)
if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken)
}
// Update user from token payload
const savedUser = localStorage.getItem('user')
if (savedUser) {
setUser(JSON.parse(savedUser))
}
} catch (error) {
logout()
}
}
const requestOtp = async (phoneNumber) => {
const res = await authApi.post('/auth/request-otp', { phone_number: phoneNumber })
return res.data
}
const verifyOtp = async (phoneNumber, otp) => {
const res = await authApi.post('/auth/verify-otp', {
phone_number: phoneNumber,
code: otp, // Auth service expects 'code' not 'otp'
})
const { access_token, refresh_token, user: userData } = res.data
localStorage.setItem('access_token', access_token)
if (refresh_token) {
localStorage.setItem('refresh_token', refresh_token)
}
localStorage.setItem('user', JSON.stringify(userData))
setUser(userData)
return res.data
}
const guestLogin = async (deviceId) => {
const res = await authApi.post('/auth/guest-login', { device_id: deviceId })
const { access_token, user: userData } = res.data
localStorage.setItem('access_token', access_token)
localStorage.setItem('user', JSON.stringify(userData))
setUser(userData)
return res.data
}
const logout = async () => {
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
await authApi.post('/auth/logout', { refresh_token: refreshToken })
} catch (error) {
console.error('Logout error:', error)
}
}
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
setUser(null)
}
return (
<AuthContext.Provider value={{
user,
loading,
requestOtp,
verifyOtp,
guestLogin,
logout,
refreshToken,
}}>
{children}
</AuthContext.Provider>
)
}

View File

@ -0,0 +1,53 @@
import { Extension } from '@tiptap/core'
import { TextStyle } from '@tiptap/extension-text-style'
export const FontSize = Extension.create({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
}
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) => {
const fontSize = element.style.fontSize
if (!fontSize) return null
return fontSize.replace('px', '')
},
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {}
}
return {
style: `font-size: ${attributes.fontSize}px`,
}
},
},
},
},
]
},
addCommands() {
return {
setFontSize:
(fontSize) =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize }).run()
},
unsetFontSize:
() =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
},
}
},
})

View File

@ -0,0 +1,185 @@
import { Extension } from '@tiptap/core'
import { Image } from '@tiptap/extension-image'
export const ImageResize = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: (element) => {
const width = element.getAttribute('width')
return width ? parseInt(width, 10) : null
},
renderHTML: (attributes) => {
if (!attributes.width) {
return {}
}
return {
width: attributes.width,
}
},
},
height: {
default: null,
parseHTML: (element) => {
const height = element.getAttribute('height')
return height ? parseInt(height, 10) : null
},
renderHTML: (attributes) => {
if (!attributes.height) {
return {}
}
return {
height: attributes.height,
}
},
},
}
},
addNodeView() {
return ({ node, HTMLAttributes, getPos, editor, selected }) => {
const dom = document.createElement('div')
dom.className = 'image-resize-wrapper'
if (selected) {
dom.classList.add('selected')
}
const img = document.createElement('img')
img.src = node.attrs.src
img.alt = node.attrs.alt || ''
img.draggable = false
img.style.display = 'block'
img.style.maxWidth = '100%'
img.style.height = 'auto'
if (node.attrs.width) {
img.style.width = `${node.attrs.width}px`
}
if (node.attrs.height) {
img.style.height = `${node.attrs.height}px`
}
// Resize handle
const resizeHandle = document.createElement('div')
resizeHandle.className = 'resize-handle'
resizeHandle.innerHTML = '↘'
resizeHandle.style.display = selected ? 'flex' : 'none'
let isResizing = false
let startX = 0
let startY = 0
let startWidth = 0
let startHeight = 0
const updateResizeHandle = () => {
if (selected && !dom.contains(resizeHandle)) {
dom.appendChild(resizeHandle)
resizeHandle.style.display = 'flex'
} else if (!selected && dom.contains(resizeHandle)) {
resizeHandle.style.display = 'none'
}
if (selected) {
dom.classList.add('selected')
} else {
dom.classList.remove('selected')
}
}
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true
startX = e.clientX
startY = e.clientY
const rect = img.getBoundingClientRect()
startWidth = rect.width
startHeight = rect.height
e.preventDefault()
e.stopPropagation()
})
const handleMouseMove = (e) => {
if (!isResizing) return
const deltaX = e.clientX - startX
const deltaY = e.clientY - startY
const aspectRatio = startHeight / startWidth
const newWidth = Math.max(100, Math.min(1200, startWidth + deltaX))
const newHeight = newWidth * aspectRatio
img.style.width = `${newWidth}px`
img.style.height = `${newHeight}px`
}
const handleMouseUp = () => {
if (!isResizing) return
isResizing = false
const width = parseInt(img.style.width, 10)
const height = parseInt(img.style.height, 10)
if (typeof getPos === 'function' && editor) {
editor.chain().setImage({ width, height }).run()
}
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
dom.appendChild(img)
updateResizeHandle()
// Update on selection change
const updateSelection = () => {
const { selection } = editor.state
const pos = typeof getPos === 'function' ? getPos() : null
if (pos !== null && pos !== undefined) {
const isSelected = selection.from <= pos && selection.to >= pos + node.nodeSize
if (isSelected !== selected) {
if (isSelected) {
dom.classList.add('selected')
if (!dom.contains(resizeHandle)) {
dom.appendChild(resizeHandle)
}
resizeHandle.style.display = 'flex'
} else {
dom.classList.remove('selected')
resizeHandle.style.display = 'none'
}
}
}
}
editor.on('selectionUpdate', updateSelection)
return {
dom,
update: (updatedNode) => {
if (updatedNode.type.name !== 'image') return false
if (updatedNode.attrs.src !== node.attrs.src) {
img.src = updatedNode.attrs.src
}
if (updatedNode.attrs.width !== node.attrs.width) {
img.style.width = updatedNode.attrs.width ? `${updatedNode.attrs.width}px` : 'auto'
}
if (updatedNode.attrs.height !== node.attrs.height) {
img.style.height = updatedNode.attrs.height ? `${updatedNode.attrs.height}px` : 'auto'
}
node = updatedNode
return true
},
destroy: () => {
editor.off('selectionUpdate', updateSelection)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
},
}
}
},
}).configure({
inline: false,
allowBase64: false,
})

122
frontend/src/index.css Normal file
View File

@ -0,0 +1,122 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ProseMirror {
outline: none;
min-height: 400px;
padding: 1rem;
}
.ProseMirror p {
margin: 0.5rem 0;
}
.ProseMirror h1 {
font-size: 2rem;
font-weight: bold;
margin: 1rem 0;
}
.ProseMirror h2 {
font-size: 1.5rem;
font-weight: bold;
margin: 0.75rem 0;
}
.ProseMirror h3 {
font-size: 1.25rem;
font-weight: bold;
margin: 0.5rem 0;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.ProseMirror blockquote {
border-left: 4px solid #ddd;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
}
.ProseMirror pre {
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
}
.ProseMirror code {
background: #f5f5f5;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1rem 0;
display: block;
}
.ProseMirror .image-resize-wrapper {
position: relative;
display: inline-block;
margin: 1rem 0;
max-width: 100%;
}
.ProseMirror .image-resize-wrapper.selected {
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 4px;
}
.ProseMirror .image-resize-wrapper img {
display: block;
max-width: 100%;
height: auto;
border-radius: 4px;
user-select: none;
cursor: default;
}
.ProseMirror .image-resize-wrapper .resize-handle {
position: absolute;
bottom: -8px;
right: -8px;
width: 24px;
height: 24px;
background-color: #3b82f6;
border: 2px solid white;
border-radius: 50%;
cursor: nwse-resize;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.ProseMirror .image-resize-wrapper .resize-handle:hover {
background-color: #2563eb;
transform: scale(1.1);
}

10
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,91 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Underline from '@tiptap/extension-underline'
import { FontSize } from '../extensions/FontSize'
import api from '../utils/api'
export default function BlogPost() {
const { slug } = useParams()
const [post, setPost] = useState(null)
const [loading, setLoading] = useState(true)
const editor = useEditor({
extensions: [
StarterKit,
Image,
TextStyle,
Color,
Underline,
FontSize,
],
content: null,
editable: false,
editorProps: {
attributes: {
class: 'ProseMirror',
},
},
})
useEffect(() => {
fetchPost()
}, [slug])
useEffect(() => {
if (post && editor) {
editor.commands.setContent(post.content_json || {})
}
}, [post, editor])
const fetchPost = async () => {
try {
const res = await api.get(`/posts/slug/${slug}`)
setPost(res.data)
} catch (error) {
console.error('Failed to load post:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>
}
if (!post) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Post not found</h1>
<p className="text-gray-600">The post you're looking for doesn't exist.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
<div className="text-sm text-gray-500">
Published on {new Date(post.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</header>
<div className="bg-white rounded-lg shadow-sm p-8">
{editor && <EditorContent editor={editor} />}
</div>
</article>
</div>
)
}

View File

@ -0,0 +1,154 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import api from '../utils/api'
import toast from 'react-hot-toast'
export default function Dashboard() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const { user, logout } = useAuth()
const navigate = useNavigate()
useEffect(() => {
fetchPosts()
}, [])
const fetchPosts = async () => {
try {
const res = await api.get('/posts')
setPosts(res.data)
} catch (error) {
toast.error('Failed to load posts')
} finally {
setLoading(false)
}
}
const handleDelete = async (id) => {
if (!window.confirm('Are you sure you want to delete this post?')) return
try {
await api.delete(`/posts/${id}`)
toast.success('Post deleted')
fetchPosts()
} catch (error) {
toast.error('Failed to delete post')
}
}
const handlePublish = async (post) => {
try {
await api.put(`/posts/${post.id}`, {
...post,
status: post.status === 'published' ? 'draft' : 'published'
})
toast.success(`Post ${post.status === 'published' ? 'unpublished' : 'published'}`)
fetchPosts()
} catch (error) {
toast.error('Failed to update post')
}
}
if (loading) {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">Blog Editor</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-gray-700">{user?.phone_number || 'Guest'}</span>
<Link
to="/editor"
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
>
New Post
</Link>
<button
onClick={logout}
className="text-gray-700 hover:text-gray-900"
>
Logout
</button>
</div>
</div>
</div>
</nav>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Your Posts</h2>
{posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">No posts yet. Create your first post!</p>
<Link
to="/editor"
className="inline-block bg-indigo-600 text-white px-6 py-3 rounded-md hover:bg-indigo-700"
>
Create Post
</Link>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<div key={post.id} className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold text-gray-900">
{post.title || 'Untitled'}
</h3>
<span
className={`px-2 py-1 text-xs rounded ${
post.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{post.status}
</span>
</div>
<p className="text-sm text-gray-500 mb-4">
{new Date(post.updated_at).toLocaleDateString()}
</p>
<div className="flex space-x-2">
<Link
to={`/editor/${post.id}`}
className="flex-1 text-center bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 text-sm"
>
Edit
</Link>
{post.status === 'published' && (
<Link
to={`/blog/${post.slug}`}
target="_blank"
className="flex-1 text-center bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 text-sm"
>
View
</Link>
)}
<button
onClick={() => handlePublish(post)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm"
>
{post.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
<button
onClick={() => handleDelete(post.id)}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,198 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import Editor from '../components/Editor'
import api from '../utils/api'
import toast from 'react-hot-toast'
export default function EditorPage() {
const { id } = useParams()
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [content, setContent] = useState(null)
const [loading, setLoading] = useState(!!id)
const [saving, setSaving] = useState(false)
const autoSaveTimeoutRef = useRef(null)
const isInitialLoadRef = useRef(true)
const currentPostIdRef = useRef(id)
useEffect(() => {
currentPostIdRef.current = id
if (id) {
fetchPost()
} else {
setLoading(false)
}
}, [id])
// Debounced auto-save function
const handleAutoSave = useCallback(async () => {
// Don't save if nothing has changed
if (!title && !content) return
// Don't save during initial load
if (isInitialLoadRef.current) return
try {
setSaving(true)
const postData = {
title: title || 'Untitled',
content_json: content || {},
status: 'draft',
}
let postId = currentPostIdRef.current
if (postId) {
// Update existing post
await api.put(`/posts/${postId}`, postData)
} else {
// Create new post
const res = await api.post('/posts', postData)
postId = res.data.id
currentPostIdRef.current = postId
// Update URL without reload
window.history.replaceState({}, '', `/editor/${postId}`)
}
} catch (error) {
console.error('Auto-save failed:', error)
// Don't show error toast for auto-save failures to avoid annoying user
} finally {
setSaving(false)
}
}, [title, content])
// Debounced save on content change
useEffect(() => {
// Skip on initial load
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false
return
}
// Clear existing timeout
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
// Set new timeout for auto-save (2 seconds after last change)
autoSaveTimeoutRef.current = setTimeout(() => {
handleAutoSave()
}, 2000)
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
}
}, [title, content, handleAutoSave])
const fetchPost = async () => {
try {
const res = await api.get(`/posts/${id}`)
const post = res.data
setTitle(post.title || '')
setContent(post.content_json || null)
isInitialLoadRef.current = true // Reset after loading
} catch (error) {
toast.error('Failed to load post')
navigate('/dashboard')
} finally {
setLoading(false)
// Allow saving after a short delay
setTimeout(() => {
isInitialLoadRef.current = false
}, 500)
}
}
const handlePublish = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (!content) {
toast.error('Please add some content')
return
}
try {
setSaving(true)
const postData = {
title: title.trim(),
content_json: content,
status: 'published',
}
let postId = currentPostIdRef.current || id
if (postId) {
await api.put(`/posts/${postId}`, postData)
} else {
const res = await api.post('/posts', postData)
postId = res.data.id
}
toast.success('Post published!')
navigate('/dashboard')
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to publish post')
} finally {
setSaving(false)
}
}
// Cleanup on unmount
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
}
}, [])
if (loading) {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<button
onClick={() => navigate('/dashboard')}
className="text-gray-700 hover:text-gray-900"
>
Back to Dashboard
</button>
<div className="flex items-center space-x-4">
{saving && (
<span className="text-sm text-gray-500">Saving...</span>
)}
<button
onClick={handlePublish}
disabled={saving}
className="bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
Publish
</button>
</div>
</div>
</div>
</nav>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<input
type="text"
placeholder="Enter post title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full text-3xl font-bold mb-6 p-2 border-0 border-b-2 border-gray-300 focus:outline-none focus:border-indigo-600 bg-transparent"
/>
<Editor
content={content}
onChange={setContent}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,137 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import toast from 'react-hot-toast'
export default function Login() {
const [phoneNumber, setPhoneNumber] = useState('')
const [otp, setOtp] = useState('')
const [step, setStep] = useState('phone') // 'phone' or 'otp'
const [loading, setLoading] = useState(false)
const { requestOtp, verifyOtp } = useAuth()
const navigate = useNavigate()
const handleRequestOtp = async (e) => {
e.preventDefault()
setLoading(true)
try {
await requestOtp(phoneNumber)
toast.success('OTP sent to your phone!')
setStep('otp')
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to send OTP')
} finally {
setLoading(false)
}
}
const handleVerifyOtp = async (e) => {
e.preventDefault()
setLoading(true)
try {
await verifyOtp(phoneNumber, otp)
toast.success('Logged in successfully!')
navigate('/dashboard')
} catch (error) {
toast.error(error.response?.data?.error || 'Invalid OTP')
} finally {
setLoading(false)
}
}
const normalizePhone = (phone) => {
const p = phone.trim().replace(/\s+/g, '')
if (p.startsWith('+')) return p
if (p.length === 10) return '+91' + p
return p
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{step === 'phone' ? 'Sign in to your account' : 'Enter OTP'}
</h2>
</div>
{step === 'phone' ? (
<form className="mt-8 space-y-6" onSubmit={handleRequestOtp}>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
Phone Number
</label>
<input
id="phone"
name="phone"
type="tel"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="+919876543210 or 9876543210"
value={phoneNumber}
onChange={(e) => setPhoneNumber(normalizePhone(e.target.value))}
/>
<p className="mt-2 text-sm text-gray-500">
We'll send you an OTP to verify your phone number
</p>
</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-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Sending OTP...' : 'Send OTP'}
</button>
</div>
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleVerifyOtp}>
<div>
<label htmlFor="otp" className="block text-sm font-medium text-gray-700">
Enter OTP
</label>
<input
id="otp"
name="otp"
type="text"
maxLength="4"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm text-center text-2xl tracking-widest"
placeholder="0000"
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
/>
<p className="mt-2 text-sm text-gray-500">
Enter the 4-digit OTP sent to {phoneNumber}
</p>
</div>
<div className="flex space-x-4">
<button
type="button"
onClick={() => {
setStep('phone')
setOtp('')
}}
className="flex-1 py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Change Number
</button>
<button
type="submit"
disabled={loading || otp.length !== 4}
className="flex-1 py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Verifying...' : 'Verify OTP'}
</button>
</div>
</form>
)}
</div>
</div>
)
}

59
frontend/src/utils/api.js Normal file
View File

@ -0,0 +1,59 @@
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000, // 30 second timeout
})
// Add token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle token expiration
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Try to refresh token
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
const authApi = (await import('./authApi')).default
const res = await authApi.post('/auth/refresh', { refresh_token: refreshToken })
const { access_token, refresh_token: newRefreshToken } = res.data
localStorage.setItem('access_token', access_token)
if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken)
}
// Retry original request
error.config.headers.Authorization = `Bearer ${access_token}`
return api.request(error.config)
} catch (refreshError) {
// Refresh failed, logout
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
}
} else {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default api

View File

@ -0,0 +1,13 @@
import axios from 'axios'
// Auth service API (separate from blog editor API)
const AUTH_API_URL = import.meta.env.VITE_AUTH_API_URL || 'http://localhost:3000'
const authApi = axios.create({
baseURL: AUTH_API_URL,
headers: {
'Content-Type': 'application/json',
},
})
export default authApi

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

15
frontend/vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 4000,
proxy: {
'/api': {
target: 'http://localhost:5001',
changeOrigin: true
}
}
}
})