Initial commit: Blog Editor application
This commit is contained in:
commit
a949eb8e57
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
build/
|
||||
# TEMPORARY: Local images folder (remove in production)
|
||||
images/*
|
||||
!images/.gitkeep
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# This file keeps the images directory in git
|
||||
# Images uploaded here are temporary for testing only
|
||||
|
|
@ -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' })
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue