10 Commits
Author SHA1 Message Date
dimitar 3718882f5b another auth checkpoint
webhook issue resolved
2026-02-04 22:36:28 +01:00
dimitar cb2355adcd types import error fixed 2026-02-04 20:37:46 +01:00
dimitar c6e602d354 docker dependecies fixed 2026-02-04 20:03:17 +01:00
dimitar 42002f8e6f auth checkpoint
auth dependecies not instaled in dev container
2026-02-04 19:24:03 +01:00
dimitar 7378d37b36 sync working as intended 2026-02-04 00:52:05 +01:00
dimitar 9ca05f5ea1 delete/archive implemented 2026-02-03 23:14:36 +01:00
dimitar 89b8687431 video func implemented 2026-02-03 21:18:25 +01:00
dimitar 3bc534489d img positioning fixed 2026-02-03 20:03:46 +01:00
dimitar 6467e21019 markdown img insert work as intended 2026-02-03 19:53:29 +01:00
dimitar 28609a6492 img shown succesfully 2026-02-03 18:47:42 +01:00
78 changed files with 9478 additions and 2299 deletions

No files matched your search

+77 -10
View File
@@ -8,6 +8,34 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
- **Backend**: NestJS + TypeORM + SQLite - **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi (in /cms) - **CMS**: Strapi (in /cms)
## Root-Level Commands
```bash
# Docker development
npm run docker:up # Start all services
npm run docker:down # Stop all services
npm run docker:build # Rebuild containers
npm run docker:logs # View logs
npm run dev:docker # Start docker dev (alias)
# Local development
npm run dev:local # Start backend & frontend locally
npm run dev:backend:local # Backend only (local env)
npm run dev:frontend:local # Frontend only (local env)
# Code quality
npm run lint # Lint both projects
npm run lint:fix # Auto-fix lint issues
npm run type-check # Type check both projects
# Database operations
npm run db:backup # Backup database
npm run db:restore # Restore database
npm run db:reset # Reset database
# Environment
npm run reset:env # Reset to docker environment
```
## Build Commands ## Build Commands
### Backend (NestJS) ### Backend (NestJS)
@@ -15,16 +43,21 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
cd backend cd backend
npm install npm install
npm run start:dev # Watch mode npm run start:dev # Watch mode
npm run start:debug # Debug mode
npm run build npm run build
npm run start:prod npm run start:prod
npm run lint # Lint npm run lint # Lint
npm run lint:fix # Auto-fix npm run lint:fix # Auto-fix
npm run type-check # TypeScript check npm run type-check # TypeScript check
npm test # All tests npm test # All tests
npm test:watch # Watch mode tests
npm test:cov # Coverage
npm test:e2e # E2E tests
npm test app.service.spec.ts # Single file npm test app.service.spec.ts # Single file
npm test -t "should" # By test name npm test -t "should return" # By test name pattern
npm run test:cov # Coverage npm run format # Format with Prettier
npm run test:e2e # E2E tests npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
``` ```
### Frontend (TanStack) ### Frontend (TanStack)
@@ -34,13 +67,16 @@ npm install
npm run dev # Vite dev server npm run dev # Vite dev server
npm run build npm run build
npm run preview npm run preview
npm run lint npm run lint # Lint
npm run lint:fix npm run lint:fix # Auto-fix
npm run type-check npm run type-check # TypeScript check
npm test # All tests npm test # All tests (Vitest)
npm test:ui # Vitest UI
npm test:coverage # Test coverage
npm test Header.test.tsx # Single file npm test Header.test.tsx # Single file
npm test -t "renders" # By name npm test -t "renders" # By test name pattern
npm run test:ui # Vitest UI npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
``` ```
## Code Style ## Code Style
@@ -51,6 +87,8 @@ npm run test:ui # Vitest UI
- Explicit return types for public methods - Explicit return types for public methods
- Prefer interfaces over types for objects - Prefer interfaces over types for objects
- Use `readonly` for immutable properties - Use `readonly` for immutable properties
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
- Frontend: Path alias `@/*` maps to `./src/*`
### Naming Conventions ### Naming Conventions
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`) - **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
@@ -62,6 +100,20 @@ npm run test:ui # Vitest UI
### Import Order ### Import Order
- External libraries first, then internal modules, then relative imports - External libraries first, then internal modules, then relative imports
- Group imports with blank lines between groups
- Example:
```typescript
// External
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Internal modules
import { AuthModule } from '../auth/auth.module';
// Relative imports
import { UserService } from './user.service';
import { UserController } from './user.controller';
```
### File Structure ### File Structure
@@ -104,12 +156,16 @@ frontend/src/
``` ```
### Testing ### Testing
- **Backend**: Jest with ts-jest transformer
- **Frontend**: Vitest with React Testing Library patterns
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert) - Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
- Integration tests for API endpoints - Integration tests for API endpoints
- E2E tests for critical user flows - E2E tests for critical user flows
- Descriptive test names that explain what is tested - Descriptive test names that explain what is tested (e.g., `should return user data when valid ID provided`)
- Mock external dependencies (use Jest for backend, Vitest for frontend) - Mock external dependencies (use Jest for backend, Vitest for frontend)
- Run `npm test` for all tests or target specific files/names - Run `npm test` for all tests or target specific files/names
- Test files: `*.spec.ts` (backend), `*.test.tsx` (frontend)
- Coverage reports: `npm test:cov` (backend), `npm test:coverage` (frontend)
### Git Workflow ### Git Workflow
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` - Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
@@ -147,5 +203,16 @@ frontend/src/
- Example backend env: `DATABASE_PATH=./data.db` - Example backend env: `DATABASE_PATH=./data.db`
- Store sensitive config in `.env` files - Store sensitive config in `.env` files
- Use `DATABASE_PATH` for SQLite location - Use `DATABASE_PATH` for SQLite location
- Use `@nestjs/config` for backend environment management
- Frontend env vars must be prefixed with `VITE_`
## Agent-Specific Instructions
- **ALWAYS** run `npm run lint` and `npm run type-check` after making changes
- **NEVER** commit changes without explicit user request
- **ALWAYS** follow existing code patterns and conventions
- **PREFER** editing existing files over creating new ones
- **VERIFY** tests pass before considering work complete
- **USE** the root-level commands for common operations
- **CHECK** both backend and frontend when making API changes
+70
View File
@@ -0,0 +1,70 @@
# Quick Strapi Webhook Setup
## TL;DR - Quick Configuration
1. **Access Strapi Admin**: `http://localhost:1337/admin`
2. **Go to Settings → Webhooks**
3. **Add New Webhook**:
- **Name**: `Backend Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
- **Events**: Select all for "Article" and "Live Blog" content types
4. **Save and Test**
## Already Configured (What We Fixed)
✅ **Backend webhook endpoints** are now public (no auth required)
✅ **Tested webhooks** manually - they work
✅ **Articles sync** from Strapi to backend
✅ **Frontend TypeScript errors** fixed
✅ **Authentication system** working
## Manual Test Commands
```bash
# Test webhook manually
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
-H "Content-Type: application/json" \
-d '{
"event": "entry.publish",
"model": "article",
"entry": {"documentId": "r07qatlpgvx82d7337n3nz1l"}
}'
# Check synced articles
curl http://localhost:3000/api/v1/articles?status=published
# Run comprehensive test
./scripts/test-webhooks.sh
```
## Current Status
- **Strapi Articles**: 2
- **Backend Articles**: 2 (synced)
- **Webhook Status**: Ready for configuration
- **Frontend**: Access at `http://localhost:5173/articles`
## Immediate Action Required
1. **Configure webhooks in Strapi admin** (see detailed guide in `STRAPI_WEBHOOKS_SETUP.md`)
2. **Test by publishing an article** in Strapi
3. **Verify automatic sync** works
## If Webhooks Don't Work
Use manual sync as fallback:
```bash
# Get auth token first
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"Test123!"}' | jq -r '.access_token')
# Sync all articles
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
-H "Authorization: Bearer $TOKEN"
```
## Verification
- Visit `http://localhost:5173/articles` to see synced articles
- Check backend logs: `docker logs placebo-backend-dev --tail 20`
- Monitor sync status with test script: `./scripts/test-webhooks.sh`
+191
View File
@@ -0,0 +1,191 @@
# Strapi Webhooks Configuration Guide
## Overview
This guide explains how to configure Strapi webhooks for automatic synchronization with the backend API. When articles or live blogs are published/updated/deleted in Strapi, webhooks will automatically trigger synchronization with the backend.
## Webhook Endpoints
The backend provides three webhook endpoints:
1. **Article-specific webhook**: `POST /api/v1/webhooks/strapi/article`
2. **Live Blog-specific webhook**: `POST /api/v1/webhooks/strapi/live-blog`
3. **Generic webhook**: `POST /api/v1/webhooks/strapi` (handles both)
All endpoints accept the following JSON format:
```json
{
"event": "entry.publish", // or "entry.update", "entry.delete", "entry.unpublish"
"model": "article", // or "live-blog"
"entry": {
"documentId": "unique-strapi-id"
}
}
```
## Configuration Steps
### Step 1: Access Strapi Admin Panel
1. Open your browser and go to: `http://localhost:1337/admin`
2. Log in with your admin credentials
### Step 2: Configure Webhooks
1. In the left sidebar, click on **Settings** (gear icon)
2. Click on **Webhooks** under the "GLOBAL SETTINGS" section
3. Click the **"Add new webhook"** button
### Step 3: Configure Article Webhook
**Basic Settings:**
- **Name**: `Backend Article Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/article`
- For Docker internal communication: `http://backend:3000/api/v1/webhooks/strapi/article`
- **Headers**: Add if needed (usually not required)
**Trigger Events:**
Select the following events for the "Article" content type:
- [x] **Create entry** (`entry.create`)
- [x] **Update entry** (`entry.update`)
- [x] **Delete entry** (`entry.delete`)
- [x] **Publish entry** (`entry.publish`)
- [x] **Unpublish entry** (`entry.unpublish`)
**Save the webhook.**
### Step 4: Configure Live Blog Webhook (Optional)
Repeat Step 3 for live blogs:
- **Name**: `Backend Live Blog Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/live-blog`
- Select events for the "Live Blog" content type
### Alternative: Single Generic Webhook
You can use a single webhook for all content types:
- **Name**: `Backend Generic Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
- Select events for ALL relevant content types (Article, Live Blog)
## Testing Webhooks
### Manual Test
You can manually trigger a webhook to test the configuration:
```bash
# Test article webhook
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/article \
-H "Content-Type: application/json" \
-d '{
"event": "entry.publish",
"model": "article",
"entry": {
"documentId": "your-article-id"
}
}'
# Test generic webhook
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
-H "Content-Type: application/json" \
-d '{
"event": "entry.publish",
"model": "article",
"entry": {
"documentId": "your-article-id"
}
}'
```
### Test from Strapi
1. In Strapi admin, edit any article
2. Click "Save" or "Publish"
3. Check backend logs for webhook receipt:
```bash
docker logs placebo-backend-dev --tail 20 | grep -i "webhook\|strapi"
```
## Manual Sync (Fallback)
If webhooks fail or for initial sync, use manual sync endpoints:
```bash
# Get authentication token first
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"your-username","password":"your-password"}'
# Sync all articles (requires auth)
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Sync all live blogs
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/live-blogs \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Sync everything
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/everything \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
## Troubleshooting
### Common Issues
1. **Webhook not received by backend**
- Check backend logs: `docker logs placebo-backend-dev --tail 50`
- Verify CORS is configured (should allow `http://localhost:1337`)
- Test webhook manually with curl
2. **401 Unauthorized error**
- Webhook endpoints are now public (no auth required)
- If getting 401, check that `@Public()` decorator is on webhook methods
3. **Webhook received but sync fails**
- Check Strapi API token in backend `.env.docker`
- Verify Strapi is accessible from backend container
- Check backend logs for specific error messages
4. **Article not appearing in frontend**
- Verify article is synced to backend: `curl http://localhost:3000/api/v1/articles`
- Check frontend browser console for errors
- Hard refresh frontend (Ctrl+F5)
### Log Monitoring
```bash
# Monitor backend logs for webhooks
docker logs -f placebo-backend-dev | grep -i "webhook\|strapi"
# Monitor Strapi logs
docker logs -f placebo-cms-dev
# Check if articles are in backend
curl -s "http://localhost:3000/api/v1/articles?status=published" | jq '.total'
```
## Docker Network Considerations
### Internal Docker Communication
- Within Docker network: Use `http://backend:3000` and `http://cms:1337`
- From host machine: Use `http://localhost:3000` and `http://localhost:1337`
### Webhook URL Examples
- **From Strapi container to Backend**: `http://backend:3000/api/v1/webhooks/strapi`
- **From Host to Backend**: `http://localhost:3000/api/v1/webhooks/strapi`
- **External/Production**: Use your domain: `https://api.yourdomain.com/api/v1/webhooks/strapi`
## Security Considerations
1. **Webhook endpoints are public** - ensure your backend is not exposed to the public internet in production
2. **Consider adding webhook signature verification** for production
3. **Use HTTPS in production** for all webhook calls
4. **Monitor webhook logs** for suspicious activity
## Verification Checklist
- [ ] Webhooks configured in Strapi admin
- [ ] Test webhook manually works
- [ ] Article publish in Strapi triggers sync
- [ ] Synced articles appear in backend API
- [ ] Frontend displays synced articles
- [ ] All event types tested (publish, update, delete)
## Support
If issues persist:
1. Check all service logs
2. Verify network connectivity between containers
3. Test each component independently
4. Review error messages in logs
+3
View File
@@ -4,6 +4,9 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install build dependencies for native modules
RUN apk add --no-cache python3 make g++
# Install dependencies with better error handling # Install dependencies with better error handling
COPY package*.json ./ COPY package*.json ./
COPY package-lock.json* ./ COPY package-lock.json* ./
+638 -342
View File
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -22,7 +22,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"dev:docker": "nest start --watch", "dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env" "dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
@@ -30,10 +31,19 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^6.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
+26
View File
@@ -0,0 +1,26 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
async function resetDatabase() {
console.log('Resetting database...');
try {
// Drop and recreate the database
const { stdout, stderr } = await execAsync(`
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "DROP DATABASE IF EXISTS placebo_backend_db;"
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "CREATE DATABASE placebo_backend_db;"
`);
if (stderr && !stderr.includes('warning')) {
console.error('Error:', stderr);
return;
}
console.log('Database reset successfully!');
} catch (error) {
console.error('Failed to reset database:', error.message);
}
}
resetDatabase();
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
echo "Resetting PostgreSQL database..."
# Connect to PostgreSQL and reset the database (without -t for TTY)
docker exec -i placebo-postgres-dev psql -U placebo_user -d postgres <<EOF
DROP DATABASE IF EXISTS placebo_backend_db;
CREATE DATABASE placebo_backend_db;
EOF
echo "Database reset complete!"
echo "The new schema will be created when the backend starts with DATABASE_SYNCHRONIZE=true"
+41
View File
@@ -0,0 +1,41 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { UserService } from '../src/modules/users/user.service';
import { UserRole } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
const userService = app.get(UserService);
try {
// Check if admin user already exists
const existingAdmin = await userService.findByUsername('admin');
if (existingAdmin) {
console.log('Admin user already exists');
await app.close();
return;
}
// Create admin user
const adminUser = await userService.create({
username: 'admin',
email: 'admin@placebo.mk',
password: 'admin123', // Change this in production!
role: UserRole.ADMIN,
isActive: true,
});
console.log('Admin user created successfully:');
console.log(`Username: ${adminUser.username}`);
console.log(`Email: ${adminUser.email}`);
console.log(`Role: ${adminUser.role}`);
console.log('\n⚠️ IMPORTANT: Change the default password immediately!');
} catch (error) {
console.error('Error creating admin user:', error);
} finally {
await app.close();
}
}
bootstrap();
+21 -3
View File
@@ -6,12 +6,18 @@ import { AppService } from './app.service';
import { ArticlesModule } from './modules/articles.module'; import { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.module'; import { StrapiModule } from './modules/strapi.module';
import { LiveBlogModule } from './modules/live-blog.module'; import { LiveBlogModule } from './modules/live-blog.module';
import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module';
import { import {
Article, Article,
Author, Author,
Category, Category,
LiveBlog, LiveBlog,
LiveBlogUpdate, LiveBlogUpdate,
User,
Comment,
Reaction,
} from './modules/entities'; } from './modules/entities';
@Module({ @Module({
@@ -26,13 +32,25 @@ import {
username: process.env.DATABASE_USERNAME || 'placebo_user', username: process.env.DATABASE_USERNAME || 'placebo_user',
password: process.env.DATABASE_PASSWORD || 'placebo_password', password: process.env.DATABASE_PASSWORD || 'placebo_password',
database: process.env.DATABASE_NAME || 'placebo_backend_db', database: process.env.DATABASE_NAME || 'placebo_backend_db',
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate], entities: [
synchronize: process.env.NODE_ENV !== 'production', Article,
logging: process.env.NODE_ENV === 'development', Author,
Category,
LiveBlog,
LiveBlogUpdate,
User,
Comment,
Reaction,
],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true',
}), }),
ArticlesModule, ArticlesModule,
StrapiModule, StrapiModule,
LiveBlogModule, LiveBlogModule,
UserModule,
AuthModule,
CommentModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+16 -1
View File
@@ -1,5 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory, Reflector } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@@ -16,6 +18,19 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
// Apply global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Apply global authentication guard
const reflector = app.get(Reflector);
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
const port = process.env.PORT ?? 3000; const port = process.env.PORT ?? 3000;
const host = '0.0.0.0'; // Bind to all interfaces for Docker const host = '0.0.0.0'; // Bind to all interfaces for Docker
await app.listen(port, host); await app.listen(port, host);
+34 -9
View File
@@ -4,10 +4,11 @@ import {
Post, Post,
Put, Put,
Delete, Delete,
Patch,
Body, Body,
Param, Param,
Query, Query,
ValidationPipe, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { import {
@@ -16,42 +17,66 @@ import {
FindArticlesDto, FindArticlesDto,
} from './articles.dto'; } from './articles.dto';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
import { Roles } from './auth/roles.decorator';
import { Public } from './auth/public.decorator';
import { UserRole } from './entities';
@Controller('articles') @Controller('articles')
export class ArticlesController { export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {} constructor(private readonly articlesService: ArticlesService) {}
@Post() @Post()
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) { @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
create(@Body() dto: CreateArticleDto) {
return this.articlesService.create(dto); return this.articlesService.create(dto);
} }
@Get() @Get()
findAll( @Public()
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto, findAll(@Query() dto: FindArticlesDto) {
) {
return this.articlesService.findAll(dto); return this.articlesService.findAll(dto);
} }
@Get(':id') @Get(':id')
@Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.articlesService.findOne(id); return this.articlesService.findOne(id);
} }
@Get('slug/:slug') @Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) { findBySlug(@Param('slug') slug: string) {
return this.articlesService.findBySlug(slug); return this.articlesService.findBySlug(slug);
} }
@Put(':id') @Put(':id')
update( @UseGuards(JwtAuthGuard, RolesGuard)
@Param('id') id: string, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto, update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
) {
return this.articlesService.update(id, dto); return this.articlesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.articlesService.remove(id); return this.articlesService.remove(id);
} }
@Patch(':id/archive')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
archive(@Param('id') id: string) {
return this.articlesService.archive(id);
}
@Patch(':id/publish')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
publish(@Param('id') id: string) {
return this.articlesService.publish(id);
}
} }
+97 -3
View File
@@ -8,7 +8,13 @@ import {
IsBoolean, IsBoolean,
IsDate, IsDate,
} from 'class-validator'; } from 'class-validator';
import { ArticleStatus, LiveBlogStatus } from './entities'; import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto { export class CreateArticleDto {
@IsString() @IsString()
@@ -49,6 +55,26 @@ export class CreateArticleDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class UpdateArticleDto { export class UpdateArticleDto {
@@ -92,6 +118,26 @@ export class UpdateArticleDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class FindArticlesDto { export class FindArticlesDto {
@@ -108,8 +154,8 @@ export class FindArticlesDto {
tag?: string; tag?: string;
@IsOptional() @IsOptional()
@IsEnum(ArticleStatus) @IsString()
status?: ArticleStatus; status?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -153,6 +199,30 @@ export class CreateLiveBlogDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class UpdateLiveBlogDto { export class UpdateLiveBlogDto {
@@ -187,6 +257,30 @@ export class UpdateLiveBlogDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class CreateLiveBlogUpdateDto { export class CreateLiveBlogUpdateDto {
+6 -2
View File
@@ -1,11 +1,15 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller'; import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities'; import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Article, Author, Category])], imports: [
TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule),
],
controllers: [ArticlesController], controllers: [ArticlesController],
providers: [ArticlesService], providers: [ArticlesService],
exports: [ArticlesService], exports: [ArticlesService],
+187 -19
View File
@@ -1,7 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Article, ArticleStatus } from './entities'; import { Article, ArticleStatus } from './entities';
import { StrapiService } from './strapi.service';
import { import {
CreateArticleDto, CreateArticleDto,
UpdateArticleDto, UpdateArticleDto,
@@ -10,9 +17,13 @@ import {
@Injectable() @Injectable()
export class ArticlesService { export class ArticlesService {
private readonly logger = new Logger(ArticlesService.name);
constructor( constructor(
@InjectRepository(Article) @InjectRepository(Article)
private readonly articleRepository: Repository<Article>, private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {} ) {}
async create(dto: CreateArticleDto): Promise<Article> { async create(dto: CreateArticleDto): Promise<Article> {
@@ -26,21 +37,23 @@ export class ArticlesService {
async findAll( async findAll(
dto: FindArticlesDto, dto: FindArticlesDto,
): Promise<{ data: Article[]; total: number }> { ): Promise<{ data: Article[]; total: number }> {
const { const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
category,
author,
tag,
status = ArticleStatus.PUBLISHED,
search,
page = 1,
limit = 10,
} = dto;
const queryBuilder = this.articleRepository const queryBuilder = this.articleRepository
.createQueryBuilder('article') .createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author') .leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.category', 'category') .leftJoinAndSelect('article.category', 'category');
.where('article.status = :status', { status });
// Handle status filter - can be single value or comma-separated list
if (status) {
if (typeof status === 'string' && status.includes(',')) {
const statuses = status.split(',').map((s) => s.trim());
queryBuilder.where('article.status IN (:...statuses)', { statuses });
} else {
queryBuilder.where('article.status = :status', { status });
}
}
// If no status specified, return all articles (for admin dashboard)
if (category) { if (category) {
queryBuilder.andWhere('category.slug = :category', { category }); queryBuilder.andWhere('category.slug = :category', { category });
@@ -98,31 +111,186 @@ export class ArticlesService {
async update(id: string, dto: UpdateArticleDto): Promise<Article> { async update(id: string, dto: UpdateArticleDto): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
const oldStatus = article.status;
Object.assign(article, dto); Object.assign(article, dto);
return await this.articleRepository.save(article); const savedArticle = await this.articleRepository.save(article);
// Update Strapi if status changed and article has strapiId
if (
dto.status !== undefined &&
dto.status !== oldStatus &&
article.strapiId
) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
dto.status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
} }
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const article = await this.findOne(id); const article = await this.findOne(id);
// Delete from Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.deleteArticleFromStrapi(article.strapiId);
} catch (error) {
// Log error but don't fail - we still want to delete from backend
this.logger.error(`Failed to delete article ${id} from Strapi:`, error);
}
}
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
} }
async archive(id: string): Promise<Article> {
const article = await this.findOne(id);
article.status = ArticleStatus.ARCHIVED;
const savedArticle = await this.articleRepository.save(article);
// Update Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
ArticleStatus.ARCHIVED,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
}
async publish(
id: string,
status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> {
const article = await this.findOne(id);
article.status = status;
const savedArticle = await this.articleRepository.save(article);
// Update Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
}
async syncFromStrapi( async syncFromStrapi(
strapiId: string, strapiId: string,
data: Partial<CreateArticleDto>, data: Partial<CreateArticleDto>,
): Promise<Article> { ): Promise<Article> {
let article = await this.articleRepository.findOne({ where: { strapiId } }); // Use upsert to handle race conditions and ensure uniqueness
try {
// First try to find existing article by strapiId
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) { if (article) {
article = this.articleRepository.create({ // Update existing article
const currentStatus = article.status;
Object.assign(article, data);
// Preserve archived status if article is already archived in our system
// unless Strapi explicitly sends a different status
if (
currentStatus === ArticleStatus.ARCHIVED &&
data.status !== ArticleStatus.ARCHIVED
) {
article.status = ArticleStatus.ARCHIVED;
}
return await this.articleRepository.save(article);
} else {
// Create new article
const newArticle = this.articleRepository.create({
strapiId, strapiId,
...data, ...data,
status: data.status || ArticleStatus.DRAFT, status: data.status || ArticleStatus.DRAFT,
}); });
} else {
Object.assign(article, data); return await this.articleRepository.save(newArticle);
}
} catch (error: unknown) {
// If we get a unique constraint violation, try to find and update again
// This handles race conditions where two syncs happen simultaneously
const dbError = error as { code?: string; constraint?: string };
if (
dbError.code === '23505' &&
dbError.constraint?.includes('strapiId')
) {
this.logger.warn(
`Race condition detected for strapiId ${strapiId}, retrying...`,
);
// Wait a bit and retry
await new Promise((resolve) => setTimeout(resolve, 100));
const existingArticle = await this.articleRepository.findOne({
where: { strapiId },
});
if (existingArticle) {
const currentStatus = existingArticle.status;
Object.assign(existingArticle, data);
if (
currentStatus === ArticleStatus.ARCHIVED &&
data.status !== ArticleStatus.ARCHIVED
) {
existingArticle.status = ArticleStatus.ARCHIVED;
} }
return await this.articleRepository.save(article); return await this.articleRepository.save(existingArticle);
}
}
// Re-throw other errors
throw error;
}
}
async removeByStrapiId(strapiId: string): Promise<void> {
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) {
this.logger.warn(`Article with strapiId ${strapiId} not found`);
return;
}
await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
} }
} }
@@ -0,0 +1,31 @@
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginUserDto, CreateUserDto } from '../users/user.dto';
import { LocalAuthGuard } from './local-auth.guard';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginUserDto: LoginUserDto) {
return this.authService.login(loginUserDto);
}
@Public()
@Post('register')
async register(@Body() createUserDto: CreateUserDto) {
return this.authService.register(createUserDto);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UserModule } from '../users/user.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const expiration = configService.get<string>('JWT_EXPIRATION') || '7d';
return {
secret:
configService.get<string>('JWT_SECRET') || 'default-secret-key',
signOptions: {
expiresIn: expiration ? parseInt(expiration) : 3600, // Convert to number
},
};
},
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from '../users/user.service';
import { CreateUserDto, LoginUserDto } from '../users/user.dto';
import { AuthResponse, JwtPayload } from './types';
import { UserRole } from '../entities';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<{
id: string;
username: string;
email: string;
role: string;
} | null> {
return this.userService.validateUser(username, password);
}
async login(loginUserDto: LoginUserDto): Promise<AuthResponse> {
const user = await this.validateUser(
loginUserDto.username,
loginUserDto.password,
);
if (!user) {
throw new Error('Invalid credentials');
}
const payload: JwtPayload = {
sub: user.id,
username: user.username,
email: user.email,
role: user.role as UserRole,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role as UserRole,
},
};
}
async register(createUserDto: CreateUserDto): Promise<AuthResponse> {
// Default new users to USER role
const userDto = {
...createUserDto,
role: UserRole.USER,
};
const user = await this.userService.create(userDto);
const payload: JwtPayload = {
sub: user.id,
username: user.username,
email: user.email,
role: user.role,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
};
}
}
@@ -0,0 +1,24 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthPublicGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
@@ -0,0 +1,9 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { JwtPayload } from './types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey:
configService.get<string>('JWT_SECRET') || 'default-secret-key',
});
}
async validate(payload: JwtPayload) {
await Promise.resolve(); // Add await to satisfy eslint rule
return {
id: payload.sub,
username: payload.username,
email: payload.email,
role: payload.role,
};
}
}
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
@@ -0,0 +1,22 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(
username: string,
password: string,
): Promise<{ id: string; username: string; email: string; role: string }> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../entities';
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../entities';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
'roles',
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const request = context.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const user = request.user as
| { id: string; username: string; email: string; role: UserRole }
| undefined;
if (!user) {
return false;
}
return requiredRoles.includes(user.role);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { UserRole } from '../entities';
export interface JwtPayload {
sub: string;
username: string;
email: string;
role: UserRole;
}
export interface RequestWithUser extends Request {
user: {
id: string;
username: string;
email: string;
role: UserRole;
};
}
export interface AuthResponse {
access_token: string;
user: {
id: string;
username: string;
email: string;
role: UserRole;
};
}
@@ -0,0 +1,148 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CommentService } from './comment.service';
import {
CreateCommentDto,
UpdateCommentDto,
CommentResponseDto,
FindCommentsDto,
CreateReactionDto,
} from './comment.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { Public } from '../auth/public.decorator';
import { UserRole } from '../entities';
import type { RequestWithUser } from '../auth/types';
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Post()
@UseGuards(JwtAuthGuard)
async create(
@Body() createCommentDto: CreateCommentDto,
@Request() req: RequestWithUser,
): Promise<CommentResponseDto> {
return this.commentService.create(createCommentDto, req.user.id);
}
@Get()
@Public()
async findAll(
@Query() findCommentsDto: FindCommentsDto,
): Promise<CommentResponseDto[]> {
return this.commentService.findAll(findCommentsDto);
}
@Get(':id')
@Public()
async findOne(@Param('id') id: string): Promise<CommentResponseDto> {
return this.commentService.findOne(id);
}
@Put(':id')
@UseGuards(JwtAuthGuard)
async update(
@Param('id') id: string,
@Body() updateCommentDto: UpdateCommentDto,
@Request() req: RequestWithUser,
): Promise<CommentResponseDto> {
return this.commentService.update(
id,
updateCommentDto,
req.user.id,
req.user.role,
);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async remove(
@Param('id') id: string,
@Request() req: RequestWithUser,
): Promise<void> {
return this.commentService.remove(id, req.user.id, req.user.role);
}
@Post('reactions')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async addReaction(
@Body() createReactionDto: CreateReactionDto,
@Request() req: RequestWithUser,
): Promise<void> {
return this.commentService.addReaction(createReactionDto, req.user.id);
}
@Get('reactions/user')
@UseGuards(JwtAuthGuard)
async getUserReaction(
@Request() req: RequestWithUser,
@Query('articleId') articleId?: string,
@Query('liveBlogId') liveBlogId?: string,
@Query('commentId') commentId?: string,
): Promise<{ type: string | null }> {
const reaction = await this.commentService.getUserReaction(
req.user.id,
articleId,
liveBlogId,
commentId,
);
return { type: reaction };
}
@Get('reactions/counts')
@Public()
async getReactionCounts(
@Query('articleId') articleId?: string,
@Query('liveBlogId') liveBlogId?: string,
@Query('commentId') commentId?: string,
): Promise<{ likes: number; dislikes: number }> {
return this.commentService.getReactionCounts(
articleId,
liveBlogId,
commentId,
);
}
// Admin endpoints
@Patch(':id/hide')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async hideComment(@Param('id') id: string): Promise<CommentResponseDto> {
return this.commentService.update(
id,
{ isVisible: false },
'admin',
'admin',
);
}
@Patch(':id/show')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async showComment(@Param('id') id: string): Promise<CommentResponseDto> {
return this.commentService.update(
id,
{ isVisible: true },
'admin',
'admin',
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import {
IsString,
IsOptional,
IsUUID,
IsBoolean,
IsInt,
Min,
Max,
} from 'class-validator';
interface CommentEntity {
id: string;
content: string;
articleId?: string;
liveBlogId?: string;
parentId?: string;
userId: string;
likeCount: number;
dislikeCount: number;
isVisible: boolean;
createdAt: Date;
updatedAt: Date;
user?: {
id: string;
username: string;
};
replies?: CommentEntity[];
}
export class CreateCommentDto {
@IsString()
@Max(5000)
content: string;
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
parentId?: string;
}
export class UpdateCommentDto {
@IsString()
@Max(5000)
@IsOptional()
content?: string;
@IsBoolean()
@IsOptional()
isVisible?: boolean;
}
export class CommentResponseDto {
id: string;
content: string;
articleId?: string;
liveBlogId?: string;
parentId?: string;
userId: string;
likeCount: number;
dislikeCount: number;
isVisible: boolean;
createdAt: Date;
updatedAt: Date;
user: {
id: string;
username: string;
};
replies?: CommentResponseDto[];
constructor(comment: CommentEntity) {
this.id = comment.id;
this.content = comment.content;
this.articleId = comment.articleId;
this.liveBlogId = comment.liveBlogId;
this.parentId = comment.parentId;
this.userId = comment.userId;
this.likeCount = comment.likeCount;
this.dislikeCount = comment.dislikeCount;
this.isVisible = comment.isVisible;
this.createdAt = comment.createdAt;
this.updatedAt = comment.updatedAt;
if (comment.user) {
this.user = {
id: comment.user.id,
username: comment.user.username,
};
}
if (comment.replies) {
this.replies = comment.replies.map(
(reply) => new CommentResponseDto(reply),
);
}
}
}
export class FindCommentsDto {
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
parentId?: string;
@IsInt()
@Min(1)
@IsOptional()
page?: number = 1;
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
}
export class CreateReactionDto {
@IsString()
type: 'like' | 'dislike';
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
commentId?: string;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Comment, Reaction } from '../entities';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
@Module({
imports: [TypeOrmModule.forFeature([Comment, Reaction])],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
})
export class CommentModule {}
@@ -0,0 +1,298 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Comment, Reaction, ReactionType } from '../entities';
import {
CreateCommentDto,
UpdateCommentDto,
CommentResponseDto,
FindCommentsDto,
CreateReactionDto,
} from './comment.dto';
@Injectable()
export class CommentService {
constructor(
@InjectRepository(Comment)
private commentRepository: Repository<Comment>,
@InjectRepository(Reaction)
private reactionRepository: Repository<Reaction>,
) {}
async create(
createCommentDto: CreateCommentDto,
userId: string,
): Promise<CommentResponseDto> {
// Validate that comment is attached to either article or live blog
if (!createCommentDto.articleId && !createCommentDto.liveBlogId) {
throw new BadRequestException(
'Comment must be attached to either an article or live blog',
);
}
// If parentId is provided, verify it exists
if (createCommentDto.parentId) {
const parent = await this.commentRepository.findOne({
where: { id: createCommentDto.parentId },
});
if (!parent) {
throw new NotFoundException('Parent comment not found');
}
}
const comment = this.commentRepository.create({
...createCommentDto,
userId,
});
const savedComment = await this.commentRepository.save(comment);
return this.findOne(savedComment.id);
}
async findAll(
findCommentsDto: FindCommentsDto,
): Promise<CommentResponseDto[]> {
const {
articleId,
liveBlogId,
parentId,
page = 1,
limit = 20,
} = findCommentsDto;
const skip = (page - 1) * limit;
const query = this.commentRepository
.createQueryBuilder('comment')
.leftJoinAndSelect('comment.user', 'user')
.leftJoinAndSelect('comment.replies', 'replies')
.leftJoinAndSelect('replies.user', 'replyUser')
.where('comment.isVisible = :isVisible', { isVisible: true });
if (articleId) {
query.andWhere('comment.articleId = :articleId', { articleId });
}
if (liveBlogId) {
query.andWhere('comment.liveBlogId = :liveBlogId', { liveBlogId });
}
if (parentId) {
query.andWhere('comment.parentId = :parentId', { parentId });
} else {
query.andWhere('comment.parentId IS NULL');
}
query.orderBy('comment.createdAt', 'DESC').skip(skip).take(limit);
const comments = await query.getMany();
return comments.map((comment) => new CommentResponseDto(comment));
}
async findOne(id: string): Promise<CommentResponseDto> {
const comment = await this.commentRepository
.createQueryBuilder('comment')
.leftJoinAndSelect('comment.user', 'user')
.leftJoinAndSelect('comment.replies', 'replies')
.leftJoinAndSelect('replies.user', 'replyUser')
.where('comment.id = :id', { id })
.andWhere('comment.isVisible = :isVisible', { isVisible: true })
.getOne();
if (!comment) {
throw new NotFoundException('Comment not found');
}
return new CommentResponseDto(comment);
}
async update(
id: string,
updateCommentDto: UpdateCommentDto,
userId: string,
userRole: string,
): Promise<CommentResponseDto> {
const comment = await this.commentRepository.findOne({
where: { id },
relations: ['user'],
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
// Only allow comment owner or admin to update
if (comment.userId !== userId && userRole !== 'admin') {
throw new BadRequestException('You can only update your own comments');
}
Object.assign(comment, updateCommentDto);
const updatedComment = await this.commentRepository.save(comment);
return this.findOne(updatedComment.id);
}
async remove(id: string, userId: string, userRole: string): Promise<void> {
const comment = await this.commentRepository.findOne({
where: { id },
relations: ['user'],
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
// Only allow comment owner or admin to delete
if (comment.userId !== userId && userRole !== 'admin') {
throw new BadRequestException('You can only delete your own comments');
}
await this.commentRepository.remove(comment);
}
async addReaction(
createReactionDto: CreateReactionDto,
userId: string,
): Promise<void> {
// Validate that reaction is attached to either article, live blog, or comment
const { articleId, liveBlogId, commentId, type } = createReactionDto;
if (!articleId && !liveBlogId && !commentId) {
throw new BadRequestException(
'Reaction must be attached to an article, live blog, or comment',
);
}
// Check if user already reacted to this item
const whereClause: {
userId: string;
articleId?: string;
liveBlogId?: string;
commentId?: string;
} = { userId };
if (articleId) whereClause.articleId = articleId;
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
if (commentId) whereClause.commentId = commentId;
const existingReaction = await this.reactionRepository.findOne({
where: whereClause,
});
if (existingReaction) {
// If same reaction type, remove it (toggle)
if (existingReaction.type === (type as ReactionType)) {
await this.reactionRepository.remove(existingReaction);
// Update comment counts if reacting to comment
if (commentId) {
await this.updateCommentReactionCount(commentId, type, -1);
}
return;
} else {
// Change reaction type
existingReaction.type = type as ReactionType;
await this.reactionRepository.save(existingReaction);
// Update comment counts if reacting to comment
if (commentId) {
// Decrement old reaction count
await this.updateCommentReactionCount(
commentId,
existingReaction.type === ReactionType.LIKE
? ReactionType.DISLIKE
: ReactionType.LIKE,
-1,
);
// Increment new reaction count
await this.updateCommentReactionCount(commentId, type, 1);
}
return;
}
}
// Create new reaction
const reaction = this.reactionRepository.create({
...createReactionDto,
userId,
type: type as ReactionType,
});
await this.reactionRepository.save(reaction);
// Update comment counts if reacting to comment
if (commentId) {
await this.updateCommentReactionCount(commentId, type, 1);
}
}
private async updateCommentReactionCount(
commentId: string,
type: 'like' | 'dislike',
change: number,
): Promise<void> {
const comment = await this.commentRepository.findOne({
where: { id: commentId },
});
if (!comment) {
return;
}
if (type === 'like') {
comment.likeCount = Math.max(0, comment.likeCount + change);
} else {
comment.dislikeCount = Math.max(0, comment.dislikeCount + change);
}
await this.commentRepository.save(comment);
}
async getUserReaction(
userId: string,
articleId?: string,
liveBlogId?: string,
commentId?: string,
): Promise<ReactionType | null> {
const whereClause: {
userId: string;
articleId?: string;
liveBlogId?: string;
commentId?: string;
} = { userId };
if (articleId) whereClause.articleId = articleId;
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
if (commentId) whereClause.commentId = commentId;
const reaction = await this.reactionRepository.findOne({
where: whereClause,
});
return reaction ? reaction.type : null;
}
async getReactionCounts(
articleId?: string,
liveBlogId?: string,
commentId?: string,
): Promise<{ likes: number; dislikes: number }> {
const query = this.reactionRepository.createQueryBuilder('reaction');
if (articleId) {
query.where('reaction.articleId = :articleId', { articleId });
} else if (liveBlogId) {
query.where('reaction.liveBlogId = :liveBlogId', { liveBlogId });
} else if (commentId) {
query.where('reaction.commentId = :commentId', { commentId });
} else {
return { likes: 0, dislikes: 0 };
}
const reactions = await query.getMany();
return {
likes: reactions.filter((r) => r.type === ReactionType.LIKE).length,
dislikes: reactions.filter((r) => r.type === ReactionType.DISLIKE).length,
};
}
}
+223 -2
View File
@@ -36,6 +36,37 @@ export enum LiveBlogStatus {
ARCHIVED = 'archived', ARCHIVED = 'archived',
} }
export enum ImagePosition {
TOP = 'top',
LEFT = 'left',
RIGHT = 'right',
NONE = 'none',
}
export enum ImageSize {
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large',
}
export enum VideoPosition {
TOP = 'top',
INLINE = 'inline',
BOTTOM = 'bottom',
NONE = 'none',
}
export enum UserRole {
ADMIN = 'admin',
CONTRIBUTOR = 'contributor',
USER = 'user',
}
export enum ReactionType {
LIKE = 'like',
DISLIKE = 'dislike',
}
@Entity('authors') @Entity('authors')
export class Author { export class Author {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -94,6 +125,36 @@ export class Category {
parent: Category; parent: Category;
} }
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column({ unique: true })
username: string;
@Column()
passwordHash: string;
@Column({
type: 'text',
default: 'user',
})
role: UserRole;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@Entity('articles') @Entity('articles')
export class Article { export class Article {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -114,6 +175,30 @@ export class Article {
@Column({ default: '' }) @Column({ default: '' })
featuredImage: string; featuredImage: string;
@Column({
type: 'text',
default: 'top',
})
imagePosition: ImagePosition;
@Column({
type: 'text',
default: 'medium',
})
imageSize: ImageSize;
@Column({ default: '' })
videoUrl: string;
@Column({
type: 'text',
default: 'inline',
})
videoPosition: VideoPosition;
@Column({ default: '' })
videoCaption: string;
@Column({ @Column({
type: 'text', type: 'text',
default: '[]', default: '[]',
@@ -130,7 +215,7 @@ export class Article {
@Column({ default: 0 }) @Column({ default: 0 })
views: number; views: number;
@Column({ nullable: true }) @Column({ nullable: true, unique: true })
strapiId: string; strapiId: string;
@Column({ nullable: true }) @Column({ nullable: true })
@@ -152,6 +237,12 @@ export class Article {
@ManyToOne(() => Category, { onDelete: 'SET NULL' }) @ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' }) @JoinColumn({ name: 'categoryId' })
category: Category; category: Category;
@OneToMany(() => Comment, (comment) => comment.article)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.article)
reactions: Reaction[];
} }
@Entity('live_blogs') @Entity('live_blogs')
@@ -177,7 +268,7 @@ export class LiveBlog {
@Column({ default: false }) @Column({ default: false })
isPinned: boolean; isPinned: boolean;
@Column({ nullable: true }) @Column({ nullable: true, unique: true })
strapiId: string; strapiId: string;
@Column({ nullable: true }) @Column({ nullable: true })
@@ -186,6 +277,33 @@ export class LiveBlog {
@Column({ nullable: true }) @Column({ nullable: true })
categoryId: string; categoryId: string;
@Column({ default: '' })
featuredImage: string;
@Column({
type: 'text',
default: 'top',
})
imagePosition: ImagePosition;
@Column({
type: 'text',
default: 'medium',
})
imageSize: ImageSize;
@Column({ default: '' })
videoUrl: string;
@Column({
type: 'text',
default: 'inline',
})
videoPosition: VideoPosition;
@Column({ default: '' })
videoCaption: string;
@Column({ default: 0 }) @Column({ default: 0 })
viewCount: number; viewCount: number;
@@ -207,6 +325,12 @@ export class LiveBlog {
cascade: true, cascade: true,
}) })
updates: LiveBlogUpdate[]; updates: LiveBlogUpdate[];
@OneToMany(() => Comment, (comment) => comment.liveBlog)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
reactions: Reaction[];
} }
@Entity('live_blog_updates') @Entity('live_blog_updates')
@@ -243,3 +367,100 @@ export class LiveBlogUpdate {
@JoinColumn({ name: 'liveBlogId' }) @JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog; liveBlog: LiveBlog;
} }
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('text')
content: string;
@Column({ nullable: true })
articleId: string;
@Column({ nullable: true })
liveBlogId: string;
@Column({ nullable: true })
parentId: string;
@Column()
userId: string;
@Column({ default: 0 })
likeCount: number;
@Column({ default: 0 })
dislikeCount: number;
@Column({ default: true })
isVisible: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'parentId' })
parent: Comment;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@OneToMany(() => Comment, (comment) => comment.parent)
replies: Comment[];
}
@Entity('reactions')
export class Reaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'text',
})
type: ReactionType;
@Column({ nullable: true })
articleId: string;
@Column({ nullable: true })
liveBlogId: string;
@Column({ nullable: true })
commentId: string;
@Column()
userId: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'commentId' })
comment: Comment;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
}
+46 -13
View File
@@ -4,13 +4,14 @@ import {
Post, Post,
Put, Put,
Delete, Delete,
Patch,
Body, Body,
Param, Param,
Query, Query,
ValidationPipe,
Res, Res,
Logger, Logger,
Headers, Headers,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response as ExpressResponse } from 'express'; import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
@@ -21,6 +22,11 @@ import {
CreateLiveBlogUpdateDto, CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto,
} from './articles.dto'; } from './articles.dto';
import { LiveBlogStatus, UserRole } from './entities';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
import { Roles } from './auth/roles.decorator';
import { Public } from './auth/public.decorator';
@Controller('live-blogs') @Controller('live-blogs')
export class LiveBlogController { export class LiveBlogController {
@@ -30,68 +36,76 @@ export class LiveBlogController {
// Live Blog CRUD operations // Live Blog CRUD operations
@Get('featured') @Get('featured')
@Public()
getFeatured() { getFeatured() {
this.logger.log('GET /featured called'); this.logger.log('GET /featured called');
return this.liveBlogService.findPinned(); return this.liveBlogService.findPinned();
} }
@Get('active') @Get('active')
@Public()
getActive() { getActive() {
return this.liveBlogService.findActive(); return this.liveBlogService.findActive();
} }
@Get('recent') @Get('recent')
@Public()
getRecent() { getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates(); return this.liveBlogService.getLiveBlogsWithRecentUpdates();
} }
@Post() @Post()
create( @UseGuards(JwtAuthGuard, RolesGuard)
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
) { create(@Body() dto: CreateLiveBlogDto) {
return this.liveBlogService.create(dto); return this.liveBlogService.create(dto);
} }
@Get() @Get()
findAll( @Public()
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto, findAll(@Query() dto: FindLiveBlogsDto) {
) {
return this.liveBlogService.findAll(dto); return this.liveBlogService.findAll(dto);
} }
@Get(':id') @Get(':id')
@Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id); return this.liveBlogService.findOne(id);
} }
@Get('slug/:slug') @Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) { findBySlug(@Param('slug') slug: string) {
return this.liveBlogService.findBySlug(slug); return this.liveBlogService.findBySlug(slug);
} }
@Put(':id') @Put(':id')
update( @UseGuards(JwtAuthGuard, RolesGuard)
@Param('id') id: string, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto, update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
) {
return this.liveBlogService.update(id, dto); return this.liveBlogService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.liveBlogService.remove(id); return this.liveBlogService.remove(id);
} }
// Live Blog Updates CRUD operations // Live Blog Updates CRUD operations
@Post(':id/updates') @Post(':id/updates')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
createUpdate( createUpdate(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto, @Body() dto: CreateLiveBlogUpdateDto,
) { ) {
return this.liveBlogService.createUpdate(dto, liveBlogId); return this.liveBlogService.createUpdate(dto, liveBlogId);
} }
@Get(':id/updates') @Get(':id/updates')
@Public()
findUpdates( findUpdates(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Query('page') page = 1, @Query('page') page = 1,
@@ -105,15 +119,19 @@ export class LiveBlogController {
} }
@Put(':id/updates/:updateId') @Put(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
updateUpdate( updateUpdate(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Param('updateId') updateId: string, @Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto, @Body() dto: UpdateLiveBlogUpdateDto,
) { ) {
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto); return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
} }
@Delete(':id/updates/:updateId') @Delete(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
removeUpdate( removeUpdate(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Param('updateId') updateId: string, @Param('updateId') updateId: string,
@@ -121,8 +139,23 @@ export class LiveBlogController {
return this.liveBlogService.removeUpdate(liveBlogId, updateId); return this.liveBlogService.removeUpdate(liveBlogId, updateId);
} }
@Patch(':id/archive')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
archive(@Param('id') id: string) {
return this.liveBlogService.archive(id);
}
@Patch(':id/publish')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
}
// SSE endpoint for real-time updates // SSE endpoint for real-time updates
@Get(':id/stream') @Get(':id/stream')
@Public()
stream( stream(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Res() response: ExpressResponse, @Res() response: ExpressResponse,
+3 -1
View File
@@ -1,14 +1,16 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller'; import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule),
], ],
controllers: [LiveBlogController], controllers: [LiveBlogController],
providers: [LiveBlogService], providers: [LiveBlogService],
+165 -13
View File
@@ -3,12 +3,15 @@ import {
NotFoundException, NotFoundException,
Logger, Logger,
OnModuleInit, OnModuleInit,
Inject,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Response } from 'express'; import { Response } from 'express';
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities'; import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
import { StrapiService } from './strapi.service';
import { import {
CreateLiveBlogDto, CreateLiveBlogDto,
UpdateLiveBlogDto, UpdateLiveBlogDto,
@@ -50,6 +53,8 @@ export class LiveBlogService implements OnModuleInit {
@InjectRepository(LiveBlogUpdate) @InjectRepository(LiveBlogUpdate)
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>, private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -116,13 +121,9 @@ export class LiveBlogService implements OnModuleInit {
} else { } else {
queryBuilder.where('liveBlog.status = :status', { status }); queryBuilder.where('liveBlog.status = :status', { status });
} }
} else if (!isPinned) {
// Default to live blogs if no status specified AND not querying for pinned blogs
// Pinned blogs can be either live or ended
queryBuilder.where('liveBlog.status = :status', {
status: LiveBlogStatus.LIVE,
});
} }
// If no status specified, return all live blogs (for admin dashboard)
// Note: Pinned blogs query should still work without status filter
if (category) { if (category) {
queryBuilder.andWhere('category.slug = :category', { category }); queryBuilder.andWhere('category.slug = :category', { category });
@@ -252,6 +253,22 @@ export class LiveBlogService implements OnModuleInit {
blogId: id, blogId: id,
status: dto.status, status: dto.status,
}); });
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
dto.status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
} }
return updatedBlog; return updatedBlog;
@@ -259,9 +276,74 @@ export class LiveBlogService implements OnModuleInit {
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const liveBlog = await this.findOne(id); const liveBlog = await this.findOne(id);
// Delete from Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId);
} catch (error) {
// Log error but don't fail - we still want to delete from backend
this.logger.error(
`Failed to delete live blog ${id} from Strapi:`,
error,
);
}
}
await this.liveBlogRepository.remove(liveBlog); await this.liveBlogRepository.remove(liveBlog);
} }
async archive(id: string): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = LiveBlogStatus.ARCHIVED;
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
LiveBlogStatus.ARCHIVED,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
return savedLiveBlog;
}
async publish(
id: string,
status: LiveBlogStatus = LiveBlogStatus.DRAFT,
): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = status;
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
return savedLiveBlog;
}
// Live Blog Update CRUD operations // Live Blog Update CRUD operations
async createUpdate( async createUpdate(
dto: CreateLiveBlogUpdateDto, dto: CreateLiveBlogUpdateDto,
@@ -380,7 +462,7 @@ export class LiveBlogService implements OnModuleInit {
const keepAliveInterval = setInterval(() => { const keepAliveInterval = setInterval(() => {
try { try {
response.write(`: keep-alive\n\n`); response.write(`: keep-alive\n\n`);
} catch (error) { } catch {
// Client disconnected, stop sending keep-alive // Client disconnected, stop sending keep-alive
clearInterval(keepAliveInterval); clearInterval(keepAliveInterval);
} }
@@ -425,21 +507,91 @@ export class LiveBlogService implements OnModuleInit {
strapiId: string, strapiId: string,
data: Partial<CreateLiveBlogDto>, data: Partial<CreateLiveBlogDto>,
): Promise<LiveBlog> { ): Promise<LiveBlog> {
let liveBlog = await this.liveBlogRepository.findOne({ // Use upsert to handle race conditions and ensure uniqueness
try {
// First try to find existing live blog by strapiId
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId }, where: { strapiId },
}); });
if (!liveBlog) { if (liveBlog) {
liveBlog = this.liveBlogRepository.create({ // Update existing live blog
const currentStatus = liveBlog.status;
Object.assign(liveBlog, data);
// Preserve archived status if live blog is already archived in our system
// unless Strapi explicitly sends a different status
if (
currentStatus === LiveBlogStatus.ARCHIVED &&
data.status !== LiveBlogStatus.ARCHIVED
) {
liveBlog.status = LiveBlogStatus.ARCHIVED;
}
return await this.liveBlogRepository.save(liveBlog);
} else {
// Create new live blog
const newLiveBlog = this.liveBlogRepository.create({
strapiId, strapiId,
...data, ...data,
status: data.status || LiveBlogStatus.DRAFT, status: data.status || LiveBlogStatus.DRAFT,
}); });
} else {
Object.assign(liveBlog, data); return await this.liveBlogRepository.save(newLiveBlog);
}
} catch (error: unknown) {
// If we get a unique constraint violation, try to find and update again
// This handles race conditions where two syncs happen simultaneously
const dbError = error as { code?: string; constraint?: string };
if (
dbError.code === '23505' &&
dbError.constraint?.includes('strapiId')
) {
this.logger.warn(
`Race condition detected for strapiId ${strapiId}, retrying...`,
);
// Wait a bit and retry
await new Promise((resolve) => setTimeout(resolve, 100));
const existingLiveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (existingLiveBlog) {
const currentStatus = existingLiveBlog.status;
Object.assign(existingLiveBlog, data);
if (
currentStatus === LiveBlogStatus.ARCHIVED &&
data.status !== LiveBlogStatus.ARCHIVED
) {
existingLiveBlog.status = LiveBlogStatus.ARCHIVED;
} }
return await this.liveBlogRepository.save(liveBlog); return await this.liveBlogRepository.save(existingLiveBlog);
}
}
// Re-throw other errors
throw error;
}
}
async removeByStrapiId(strapiId: string): Promise<void> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (!liveBlog) {
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
return;
}
await this.liveBlogRepository.remove(liveBlog);
this.logger.log(
`Successfully deleted live blog with strapiId: ${strapiId}`,
);
} }
// Utility methods // Utility methods
+10 -1
View File
@@ -1,8 +1,14 @@
import { Controller, Post, Body, Logger } from '@nestjs/common'; import { Controller, Post, Body, Logger } from '@nestjs/common';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { Public } from './auth/public.decorator';
interface WebhookBody { interface WebhookBody {
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish'; event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish';
model: string; model: string;
entry: { entry: {
documentId: string; documentId: string;
@@ -16,6 +22,7 @@ export class StrapiController {
constructor(private readonly strapiService: StrapiService) {} constructor(private readonly strapiService: StrapiService) {}
@Post('article') @Post('article')
@Public()
async handleArticleWebhook(@Body() body: WebhookBody) { async handleArticleWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`); this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body; const { event, model, entry } = body;
@@ -33,6 +40,7 @@ export class StrapiController {
} }
@Post('live-blog') @Post('live-blog')
@Public()
async handleLiveBlogWebhook(@Body() body: WebhookBody) { async handleLiveBlogWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`); this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body; const { event, model, entry } = body;
@@ -50,6 +58,7 @@ export class StrapiController {
} }
@Post() @Post()
@Public()
async handleGenericWebhook(@Body() body: unknown) { async handleGenericWebhook(@Body() body: unknown) {
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`); this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
+6 -2
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller'; import { StrapiController } from './strapi.controller';
@@ -6,7 +6,11 @@ import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module'; import { LiveBlogModule } from './live-blog.module';
@Module({ @Module({
imports: [HttpModule, ArticlesModule, LiveBlogModule], imports: [
HttpModule,
forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule),
],
controllers: [StrapiController], controllers: [StrapiController],
providers: [StrapiService], providers: [StrapiService],
exports: [StrapiService], exports: [StrapiService],
+352 -22
View File
@@ -1,11 +1,32 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto'; import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
import { ArticleStatus, LiveBlogStatus } from './entities'; import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
interface StrapiImage {
url: string;
alternativeText?: string;
caption?: string;
width?: number;
height?: number;
formats?: Record<string, StrapiImageFormat>;
}
interface StrapiImageFormat {
url: string;
width: number;
height: number;
}
interface StrapiArticle { interface StrapiArticle {
id: number; id: number;
@@ -17,6 +38,13 @@ interface StrapiArticle {
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@@ -29,6 +57,13 @@ interface StrapiLiveBlog {
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@@ -52,7 +87,9 @@ export class StrapiService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
@Inject(forwardRef(() => ArticlesService))
private readonly articlesService: ArticlesService, private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService, private readonly liveBlogService: LiveBlogService,
) { ) {
this.strapiUrl = this.strapiUrl =
@@ -71,13 +108,67 @@ export class StrapiService {
return headers; return headers;
} }
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
// Try to get image from img field first (single image)
let imageUrl: string | undefined;
if (strapiArticle.img?.url) {
imageUrl = strapiArticle.img.url;
} else if (strapiArticle.media?.[0]?.url) {
// Try to get first image from media field (multiple images)
imageUrl = strapiArticle.media[0].url;
}
if (!imageUrl) {
return undefined;
}
// If URL is relative, prepend Strapi base URL
if (imageUrl.startsWith('/')) {
// Convert Docker service URL to localhost for frontend access
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
private extractLiveBlogImageUrl(
strapiLiveBlog: StrapiLiveBlog,
): string | undefined {
// Try to get image from img field first (single image)
let imageUrl: string | undefined;
if (strapiLiveBlog.img?.url) {
imageUrl = strapiLiveBlog.img.url;
} else if (strapiLiveBlog.media?.[0]?.url) {
// Try to get first image from media field (multiple images)
imageUrl = strapiLiveBlog.media[0].url;
}
if (!imageUrl) {
return undefined;
}
// If URL is relative, prepend Strapi base URL
if (imageUrl.startsWith('/')) {
// Convert Docker service URL to localhost for frontend access
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
async syncArticles(): Promise<void> { async syncArticles(): Promise<void> {
try { try {
this.logger.log('Starting articles sync from Strapi...'); this.logger.log('Starting articles sync from Strapi...');
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>( this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles`, `${this.strapiUrl}/api/articles?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -88,6 +179,8 @@ export class StrapiService {
let syncedCount = 0; let syncedCount = 0;
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -97,6 +190,14 @@ export class StrapiService {
? ArticleStatus.PUBLISHED ? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT, : ArticleStatus.DRAFT,
tags: [], tags: [],
featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition ||
'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -117,14 +218,21 @@ export class StrapiService {
async syncSingleArticle( async syncSingleArticle(
strapiId: string, strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> { ): Promise<void> {
try { try {
this.logger.log(`Syncing single article from Strapi: ${strapiId}, event: ${event}`); this.logger.log(
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>( this.httpService.get<StrapiResponse<StrapiArticle>>(
`${this.strapiUrl}/api/articles/${strapiId}`, `${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -146,6 +254,8 @@ export class StrapiService {
status = ArticleStatus.DRAFT; status = ArticleStatus.DRAFT;
} }
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -153,17 +263,29 @@ export class StrapiService {
slug: strapiArticle.slug, slug: strapiArticle.slug,
status, status,
tags: [], tags: [],
featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
strapiArticle.documentId, strapiArticle.documentId,
articleData, articleData,
); );
this.logger.log(`Successfully synced article: ${strapiArticle.title} with status: ${status}`); this.logger.log(
} catch (error: any) { `Successfully synced article: ${strapiArticle.title} with status: ${status}`,
);
} catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft // If we get a 404 and it's an unpublish event, we can still mark it as draft
if (event === 'entry.unpublish' && error.response?.status === 404) { const axiosError = error as { response?: { status: number } };
this.logger.log(`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`); if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
this.logger.log(
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
);
// Try to update the article status to draft directly // Try to update the article status to draft directly
try { try {
@@ -172,7 +294,9 @@ export class StrapiService {
}; };
await this.articlesService.syncFromStrapi(strapiId, articleData); await this.articlesService.syncFromStrapi(strapiId, articleData);
this.logger.log(`Marked article ${strapiId} as draft based on unpublish event`); this.logger.log(
`Marked article ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) { } catch (syncError) {
this.logger.error( this.logger.error(
`Failed to mark article ${strapiId} as draft:`, `Failed to mark article ${strapiId} as draft:`,
@@ -196,7 +320,7 @@ export class StrapiService {
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>( this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs`, `${this.strapiUrl}/api/live-blogs?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -207,11 +331,21 @@ export class StrapiService {
let syncedCount = 0; let syncedCount = 0;
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug, slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status), status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
featuredImage: imageUrl,
imagePosition: (strapiLiveBlog.imagePosition ||
'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -232,14 +366,21 @@ export class StrapiService {
async syncSingleLiveBlog( async syncSingleLiveBlog(
strapiId: string, strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> { ): Promise<void> {
try { try {
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`); this.logger.log(
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>( this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}`, `${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -257,22 +398,36 @@ export class StrapiService {
status = LiveBlogStatus.DRAFT; status = LiveBlogStatus.DRAFT;
} }
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug, slug: strapiLiveBlog.slug,
status, status,
featuredImage: imageUrl,
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
strapiLiveBlog.documentId, strapiLiveBlog.documentId,
liveBlogData, liveBlogData,
); );
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`); this.logger.log(
} catch (error: any) { `Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
);
} catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft // If we get a 404 and it's an unpublish event, we can still mark it as draft
if (event === 'entry.unpublish' && error.response?.status === 404) { const axiosError = error as { response?: { status: number } };
this.logger.log(`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`); if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
this.logger.log(
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
);
// Try to update the live blog status to draft directly // Try to update the live blog status to draft directly
try { try {
@@ -281,7 +436,9 @@ export class StrapiService {
}; };
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData); await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
this.logger.log(`Marked live blog ${strapiId} as draft based on unpublish event`); this.logger.log(
`Marked live blog ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) { } catch (syncError) {
this.logger.error( this.logger.error(
`Failed to mark live blog ${strapiId} as draft:`, `Failed to mark live blog ${strapiId} as draft:`,
@@ -315,7 +472,12 @@ export class StrapiService {
} }
async handleWebhook( async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string }, data: { documentId: string; model?: string },
): Promise<void> { ): Promise<void> {
this.logger.log( this.logger.log(
@@ -323,7 +485,17 @@ export class StrapiService {
); );
if (event === 'entry.delete') { if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}`); this.logger.log(
`Handling delete for document: ${data.documentId}, model: ${data.model}`,
);
if (data.model === 'article') {
await this.articlesService.removeByStrapiId(data.documentId);
} else if (data.model === 'live-blog') {
await this.liveBlogService.removeByStrapiId(data.documentId);
} else {
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
}
return; return;
} }
@@ -336,4 +508,162 @@ export class StrapiService {
this.logger.warn(`Unknown model type in webhook: ${data.model}`); this.logger.warn(`Unknown model type in webhook: ${data.model}`);
} }
} }
async updateArticleStatusInStrapi(
strapiId: string,
status: ArticleStatus,
): Promise<void> {
try {
this.logger.log(
`Updating article ${strapiId} status in Strapi to: ${status}`,
);
// Map our status to Strapi status
let strapiStatus: string;
switch (status) {
case ArticleStatus.DRAFT:
strapiStatus = 'draft';
break;
case ArticleStatus.PUBLISHED:
strapiStatus = 'published';
break;
case ArticleStatus.ARCHIVED:
strapiStatus = 'archived';
break;
default:
strapiStatus = 'draft';
}
// For archived status, we need to unpublish first if it's published
if (status === ArticleStatus.ARCHIVED) {
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/articles/${strapiId}/unpublish`,
{},
{ headers: this.getHeaders() },
),
);
}
// Update the status
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/articles/${strapiId}`,
{ data: { status: strapiStatus } },
{ headers: this.getHeaders() },
),
);
this.logger.log(
`Successfully updated article ${strapiId} status in Strapi to: ${status}`,
);
} catch (error) {
this.logger.error(
`Failed to update article ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async updateLiveBlogStatusInStrapi(
strapiId: string,
status: LiveBlogStatus,
): Promise<void> {
try {
this.logger.log(
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
);
// Map our status to Strapi status
let strapiStatus: string;
switch (status) {
case LiveBlogStatus.DRAFT:
strapiStatus = 'draft';
break;
case LiveBlogStatus.LIVE:
strapiStatus = 'live';
break;
case LiveBlogStatus.ENDED:
strapiStatus = 'ended';
break;
case LiveBlogStatus.ARCHIVED:
strapiStatus = 'archived';
break;
default:
strapiStatus = 'draft';
}
// For archived status, we need to unpublish first if it's published
if (status === LiveBlogStatus.ARCHIVED) {
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`,
{},
{ headers: this.getHeaders() },
),
);
}
// Update the status
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{ data: { status: strapiStatus } },
{ headers: this.getHeaders() },
),
);
this.logger.log(
`Successfully updated live blog ${strapiId} status in Strapi to: ${status}`,
);
} catch (error) {
this.logger.error(
`Failed to update live blog ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async deleteArticleFromStrapi(strapiId: string): Promise<void> {
try {
this.logger.log(`Deleting article ${strapiId} from Strapi`);
await lastValueFrom(
this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, {
headers: this.getHeaders(),
}),
);
this.logger.log(`Successfully deleted article ${strapiId} from Strapi`);
} catch (error) {
this.logger.error(
`Failed to delete article ${strapiId} from Strapi:`,
error,
);
throw error;
}
}
async deleteLiveBlogFromStrapi(strapiId: string): Promise<void> {
try {
this.logger.log(`Deleting live blog ${strapiId} from Strapi`);
await lastValueFrom(
this.httpService.delete(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{ headers: this.getHeaders() },
),
);
this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`);
} catch (error) {
this.logger.error(
`Failed to delete live blog ${strapiId} from Strapi:`,
error,
);
throw error;
}
}
} }
@@ -0,0 +1,104 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UserService } from './user.service';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
ChangePasswordDto,
} from './user.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../entities';
import type { RequestWithUser } from '../auth/types';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.userService.create(createUserDto);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async findAll(): Promise<UserResponseDto[]> {
return this.userService.findAll();
}
@Get('profile')
@UseGuards(JwtAuthGuard)
async getProfile(@Request() req: RequestWithUser): Promise<UserResponseDto> {
return this.userService.findOne(req.user.id);
}
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.findOne(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return this.userService.update(id, updateUserDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string): Promise<void> {
return this.userService.remove(id);
}
@Post(':id/change-password')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async changePassword(
@Param('id') id: string,
@Body() changePasswordDto: ChangePasswordDto,
@Request() req: RequestWithUser,
): Promise<void> {
// Users can only change their own password, unless they're admin
if (req.user.role !== UserRole.ADMIN && req.user.id !== id) {
throw new Error('You can only change your own password');
}
return this.userService.changePassword(id, changePasswordDto);
}
@Patch(':id/deactivate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async deactivate(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.update(id, { isActive: false });
}
@Patch(':id/activate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async activate(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.update(id, { isActive: true });
}
}
+111
View File
@@ -0,0 +1,111 @@
import {
IsEmail,
IsString,
IsEnum,
IsBoolean,
IsOptional,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
import { UserRole } from '../entities';
interface UserEntity {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
username: string;
@IsString()
@MinLength(6)
password: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole = UserRole.USER;
@IsBoolean()
@IsOptional()
isActive?: boolean = true;
}
export class UpdateUserDto {
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
@IsOptional()
username?: string;
@IsString()
@MinLength(6)
@IsOptional()
password?: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class LoginUserDto {
@IsString()
username: string;
@IsString()
password: string;
}
export class UserResponseDto {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
constructor(user: UserEntity) {
this.id = user.id;
this.email = user.email;
this.username = user.username;
this.role = user.role;
this.isActive = user.isActive;
this.createdAt = user.createdAt;
this.updatedAt = user.updatedAt;
}
}
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(6)
newPassword: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+212
View File
@@ -0,0 +1,212 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User, UserRole } from '../entities';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
ChangePasswordDto,
} from './user.dto';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
// Check if email already exists
const existingEmail = await this.userRepository.findOne({
where: { email: createUserDto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
// Check if username already exists
const existingUsername = await this.userRepository.findOne({
where: { username: createUserDto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
// Create user
const user = this.userRepository.create({
...createUserDto,
passwordHash,
});
const savedUser = await this.userRepository.save(user);
return new UserResponseDto(savedUser);
}
async findAll(): Promise<UserResponseDto[]> {
const users = await this.userRepository.find({
order: { createdAt: 'DESC' },
});
return users.map((user) => new UserResponseDto(user));
}
async findOne(id: string): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
return new UserResponseDto(user);
}
async findByUsername(username: string): Promise<User | null> {
return this.userRepository.findOne({ where: { username } });
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async update(
id: string,
updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Check if email is being changed and if it already exists
if (updateUserDto.email && updateUserDto.email !== user.email) {
const existingEmail = await this.userRepository.findOne({
where: { email: updateUserDto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
}
// Check if username is being changed and if it already exists
if (updateUserDto.username && updateUserDto.username !== user.username) {
const existingUsername = await this.userRepository.findOne({
where: { username: updateUserDto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
}
// Hash new password if provided
if (updateUserDto.password) {
const saltRounds = 10;
updateUserDto.password = await bcrypt.hash(
updateUserDto.password,
saltRounds,
);
}
// Update user
Object.assign(user, updateUserDto);
if (updateUserDto.password) {
user.passwordHash = updateUserDto.password;
delete updateUserDto.password;
}
const updatedUser = await this.userRepository.save(user);
return new UserResponseDto(updatedUser);
}
async remove(id: string): Promise<void> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Prevent deleting the last admin
if (user.role === UserRole.ADMIN) {
const adminCount = await this.userRepository.count({
where: { role: UserRole.ADMIN },
});
if (adminCount <= 1) {
throw new BadRequestException('Cannot delete the last admin user');
}
}
await this.userRepository.remove(user);
}
async changePassword(
id: string,
changePasswordDto: ChangePasswordDto,
): Promise<void> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Verify current password
const isPasswordValid = await bcrypt.compare(
changePasswordDto.currentPassword,
user.passwordHash,
);
if (!isPasswordValid) {
throw new BadRequestException('Current password is incorrect');
}
// Hash new password
const saltRounds = 10;
user.passwordHash = await bcrypt.hash(
changePasswordDto.newPassword,
saltRounds,
);
await this.userRepository.save(user);
}
async validateUser(username: string, password: string): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { username, isActive: true },
});
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return null;
}
return user;
}
async createAdminIfNotExists(): Promise<void> {
const adminExists = await this.userRepository.findOne({
where: { role: UserRole.ADMIN },
});
if (!adminExists) {
const saltRounds = 10;
const passwordHash = await bcrypt.hash('admin123', saltRounds);
const adminUser = this.userRepository.create({
email: 'admin@placebo.mk',
username: 'admin',
passwordHash,
role: UserRole.ADMIN,
isActive: true,
});
await this.userRepository.save(adminUser);
console.log('Default admin user created: admin / admin123');
}
}
}
@@ -39,6 +39,30 @@
"videos", "videos",
"audios" "audios"
] ]
},
"imagePosition": {
"type": "enumeration",
"enum": ["top", "left", "right", "none"],
"default": "top"
},
"imageSize": {
"type": "enumeration",
"enum": ["small", "medium", "large"],
"default": "medium"
},
"videoUrl": {
"type": "string",
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
"default": ""
},
"videoPosition": {
"type": "enumeration",
"enum": ["top", "inline", "bottom", "none"],
"default": "inline"
},
"videoCaption": {
"type": "string",
"default": ""
} }
} }
} }
+12
View File
@@ -446,6 +446,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
imagePosition: Schema.Attribute.Enumeration<
['top', 'left', 'right', 'none']
> &
Schema.Attribute.DefaultTo<'top'>;
imageSize: Schema.Attribute.Enumeration<['small', 'medium', 'large']> &
Schema.Attribute.DefaultTo<'medium'>;
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>; img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
locale: Schema.Attribute.String & Schema.Attribute.Private; locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation< localizations: Schema.Attribute.Relation<
@@ -462,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
videoCaption: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
videoPosition: Schema.Attribute.Enumeration<
['top', 'inline', 'bottom', 'none']
> &
Schema.Attribute.DefaultTo<'inline'>;
videoUrl: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
}; };
} }
+3 -3
View File
@@ -24,9 +24,9 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: placebo-backend-dev container_name: placebo-backend-dev
env_file: env_file:
- ./backend/.env # Use the .env file for configuration - ./backend/.env.docker # Use the .env.docker file for Docker configuration
environment: environment:
# Only override if needed, most config is in .env file # Only override if needed, most config is in .env.docker file
NODE_ENV: development NODE_ENV: development
ports: ports:
- "3000:3000" - "3000:3000"
@@ -36,7 +36,7 @@ services:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/package.json:/app/package.json - ./backend/package.json:/app/package.json
- ./backend/package-lock.json:/app/package-lock.json - ./backend/package-lock.json:/app/package-lock.json
- ./backend/.env:/app/.env # Mount .env file into container - ./backend/.env.docker:/app/.env # Mount .env.docker file as .env in container
command: npm run dev:docker command: npm run dev:docker
networks: networks:
- placebo-network-dev - placebo-network-dev
+3 -5
View File
@@ -6,7 +6,7 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
container_name: placebo-postgres container_name: placebo-postgres
environment: environment:
POSTGRES_DB: placebo_db POSTGRES_DB: placebo_backend_db
POSTGRES_USER: placebo_user POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: placebo_password POSTGRES_PASSWORD: placebo_password
volumes: volumes:
@@ -15,7 +15,7 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_db"] test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_backend_db"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -35,11 +35,9 @@ services:
DATABASE_PORT: 5432 DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password DATABASE_PASSWORD: placebo_password
DATABASE_NAME: placebo_db DATABASE_NAME: placebo_backend_db
DATABASE_SYNCHRONIZE: "false" DATABASE_SYNCHRONIZE: "false"
DATABASE_LOGGING: "false" DATABASE_LOGGING: "false"
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
JWT_EXPIRATION: 3600
CORS_ORIGIN: http://localhost:5173,http://localhost:3001 CORS_ORIGIN: http://localhost:5173,http://localhost:3001
PORT: 3000 PORT: 3000
ports: ports:
+1471 -22
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -28,7 +28,9 @@
"@tanstack/react-router": "^1.144.0", "@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
+2 -2
View File
@@ -24,7 +24,7 @@ export function ArticleTicker() {
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`${article.id}-${index}`} key={`${article.id}-${index}`}
to={`/articles/${article.id}` as any} to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
> >
{article.title || 'No title'} {article.title || 'No title'}
@@ -34,7 +34,7 @@ export function ArticleTicker() {
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`dup-${article.id}-${index}`} key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}` as any} to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
> >
{article.title || 'No title'} {article.title || 'No title'}
@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Label } from '../ui/label';
interface LoginFormProps {
onSuccess?: () => void;
onSwitchToRegister?: () => void;
}
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
const { login, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(username, password);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
{onSwitchToRegister && (
<div className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<button
type="button"
onClick={onSwitchToRegister}
className="text-primary hover:underline"
disabled={isLoading}
>
Register here
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
import React from 'react';
import { Navigate } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'admin' | 'contributor' | 'user';
redirectTo?: string;
}
export function ProtectedRoute({
children,
requiredRole,
redirectTo = '/'
}: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={redirectTo} />;
}
if (requiredRole && user?.role !== requiredRole) {
return <Navigate to="/" />;
}
return <>{children}</>;
}
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Label } from '../ui/label';
interface RegisterFormProps {
onSuccess?: () => void;
onSwitchToLogin?: () => void;
}
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
const { register, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username, email, password);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>
Create a new account to start commenting and reacting
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Choose a password (min 6 characters)"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Register'}
</Button>
{onSwitchToLogin && (
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<button
type="button"
onClick={onSwitchToLogin}
className="text-primary hover:underline"
disabled={isLoading}
>
Login here
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { useComments, useCreateComment } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import { Card, CardContent } from '../../ui/card';
import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
interface CommentSectionProps {
articleId?: string;
liveBlogId?: string;
}
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
const { isAuthenticated } = useAuth();
const [newComment, setNewComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: commentsData, isLoading } = useComments({
articleId,
liveBlogId,
limit: 50,
});
const createCommentMutation = useCreateComment();
const comments = commentsData?.data || [];
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !isAuthenticated) return;
setIsSubmitting(true);
try {
await createCommentMutation.mutateAsync({
content: newComment,
articleId,
liveBlogId,
});
setNewComment('');
} catch (error) {
console.error('Failed to post comment:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mt-12 pt-8 border-t">
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
{isAuthenticated ? (
<Card className="mb-8">
<CardContent className="pt-6">
<form onSubmit={handleSubmitComment}>
<Textarea
placeholder="Што мислите за овој напис?"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[100px] mb-4"
disabled={isSubmitting}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={!newComment.trim() || isSubmitting}
>
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
</Button>
</div>
</form>
</CardContent>
</Card>
) : (
<Card className="mb-8">
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground mb-4">
Најавете се за да можете да коментирате
</p>
<Button asChild>
<a href="/auth">Најави се</a>
</Button>
</CardContent>
</Card>
)}
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување коментари...</p>
</div>
) : comments.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground">
Сè уште нема коментари. Бидете првиот што ќе коментира!
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="pt-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="font-medium">
{comment.user?.username || 'Анонимен корисник'}
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
</div>
</div>
{comment.user?.role === 'admin' && (
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
Администратор
</span>
)}
</div>
<p className="whitespace-pre-wrap">{comment.content}</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
import { useAuth } from '../../../contexts/AuthContext';
import { useReactionCounts, useUserReaction, useAddReaction } from '../../../queries/comments';
import { Button } from '../../ui/button';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
interface ReactionButtonsProps {
articleId?: string;
liveBlogId?: string;
commentId?: string;
compact?: boolean;
}
export function ReactionButtons({
articleId,
liveBlogId,
commentId,
compact = false
}: ReactionButtonsProps) {
const { isAuthenticated } = useAuth();
const { data: counts } = useReactionCounts(articleId, liveBlogId, commentId);
const { data: userReaction } = useUserReaction(articleId, liveBlogId, commentId);
const addReactionMutation = useAddReaction();
const userReactionType = userReaction?.type;
const handleReaction = async (type: 'like' | 'dislike') => {
if (!isAuthenticated) {
window.location.href = '/auth';
return;
}
try {
await addReactionMutation.mutateAsync({
type,
articleId,
liveBlogId,
commentId,
});
} catch (error) {
console.error('Failed to add reaction:', error);
}
};
const likes = counts?.likes || 0;
const dislikes = counts?.dislikes || 0;
if (compact) {
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleReaction('like')}
className={`h-8 px-2 ${userReactionType === 'like' ? 'text-primary' : ''}`}
>
<ThumbsUp className="w-4 h-4 mr-1" />
{likes > 0 && <span>{likes}</span>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReaction('dislike')}
className={`h-8 px-2 ${userReactionType === 'dislike' ? 'text-destructive' : ''}`}
>
<ThumbsDown className="w-4 h-4 mr-1" />
{dislikes > 0 && <span>{dislikes}</span>}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={userReactionType === 'like' ? 'default' : 'outline'}
size="sm"
onClick={() => handleReaction('like')}
className="gap-2"
>
<ThumbsUp className="w-4 h-4" />
<span>Допаѓа ми</span>
{likes > 0 && (
<span className="ml-1 bg-primary/20 px-2 py-0.5 rounded-full text-xs">
{likes}
</span>
)}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={userReactionType === 'dislike' ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleReaction('dislike')}
className="gap-2"
>
<ThumbsDown className="w-4 h-4" />
<span>Не ми се допаѓа</span>
{dislikes > 0 && (
<span className="ml-1 bg-destructive/20 px-2 py-0.5 rounded-full text-xs">
{dislikes}
</span>
)}
</Button>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Link } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
export function Header() {
const { user, logout, isAuthenticated, hasRole } = useAuth();
return (
<header className="border-b">
<div className="container mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">
<Link to="/" className="hover:underline">Placebo.mk</Link>
</h1>
<nav className="flex items-center gap-4">
<Link to="/" className="text-sm font-medium hover:underline">
Home
</Link>
<Link to="/articles" className="text-sm font-medium hover:underline">
Articles
</Link>
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
Live
</Link>
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
Admin
</Link>
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
+ New Live Blog
</Link>
</>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{user?.username}
</span>
<Button
variant="outline"
size="sm"
onClick={logout}
className="text-xs"
>
Logout
</Button>
</div>
</>
) : (
<Link to="/auth" className="text-sm font-medium hover:underline text-primary">
Login / Register
</Link>
)}
</nav>
</div>
</div>
</header>
);
}
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs'; import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles } from '@/queries/articles'; import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -8,12 +10,98 @@ import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 }); // State for confirmation dialog and filters
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 }); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{
type: 'article' | 'liveBlog';
id: string;
title: string;
} | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
limit: 50,
status: showArchived ? 'archived' : 'draft,live,ended'
});
const { data: articlesData, isLoading: loadingArticles } = useArticles({
limit: 50,
status: showArchived ? 'archived' : 'draft,published'
});
const deleteArticleMutation = useDeleteArticle();
const deleteLiveBlogMutation = useDeleteLiveBlog();
const archiveArticleMutation = useArchiveArticle();
const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog();
const liveBlogs = liveBlogsData?.data || []; const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || []; const articles = articlesData?.data || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('delete');
setShowConfirmDialog(true);
};
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('archive');
setShowConfirmDialog(true);
};
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
setIsProcessing(true);
try {
if (type === 'article') {
await publishArticleMutation.mutateAsync({ id, status: 'published' });
} else {
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
}
} catch (error) {
console.error('Failed to publish:', error);
} finally {
setIsProcessing(false);
}
};
const handleConfirmAction = async () => {
if (!itemToDelete) return;
setIsProcessing(true);
try {
if (dialogType === 'delete') {
if (itemToDelete.type === 'article') {
await deleteArticleMutation.mutateAsync(itemToDelete.id);
} else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
}
} else { // archive
if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else {
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
}
}
setShowConfirmDialog(false);
setItemToDelete(null);
} catch (error) {
console.error(`Failed to ${dialogType}:`, error);
} finally {
setIsProcessing(false);
}
};
const handleCancelAction = () => {
setShowConfirmDialog(false);
setItemToDelete(null);
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'published': case 'published':
@@ -50,6 +138,12 @@ export function AdminDashboardComponent() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link> <Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button> </Button>
@@ -64,9 +158,9 @@ export function AdminDashboardComponent() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Live блогови</span> <span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
<Badge variant="outline" className="ml-2"> <Badge variant="outline" className="ml-2">
{liveBlogs.length || 0} {filteredLiveBlogs.length || 0}
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -79,16 +173,20 @@ export function AdminDashboardComponent() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : liveBlogs.length === 0 ? ( ) : filteredLiveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">Нема live блогови</p> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4"> <Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link> <Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button> </Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{liveBlogs.map((blog) => ( {filteredLiveBlogs.map((blog) => (
<div <div
key={blog.id} key={blog.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
@@ -97,7 +195,8 @@ export function AdminDashboardComponent() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Link <Link
to={`/admin/live-blogs/${blog.slug}` as any} to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }}
className="font-medium hover:text-primary hover:underline" className="font-medium hover:text-primary hover:underline"
> >
{blog.title} {blog.title}
@@ -123,13 +222,40 @@ export function AdminDashboardComponent() {
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link to={`/admin/live-blogs/${blog.slug}` as any}>Уреди</Link> <Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to={`/live-blogs/${blog.slug}` as any} target="_blank"> <Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
Преглед Преглед
</Link> </Link>
</Button> </Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -143,9 +269,9 @@ export function AdminDashboardComponent() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Написи</span> <span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
<Badge variant="outline" className="ml-2"> <Badge variant="outline" className="ml-2">
{articles.length || 0} {filteredArticles.length || 0}
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -158,16 +284,20 @@ export function AdminDashboardComponent() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : articles.length === 0 ? ( ) : filteredArticles.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">Нема написи</p> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4"> <Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link> <Link to="/">Креирај нов напис</Link>
</Button> </Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{articles.map((article) => ( {filteredArticles.map((article) => (
<div <div
key={article.id} key={article.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
@@ -176,7 +306,8 @@ export function AdminDashboardComponent() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Link <Link
to={`/articles/${article.id}` as any} to="/articles/$id"
params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline" className="font-medium hover:text-primary hover:underline"
> >
{article.title} {article.title}
@@ -202,13 +333,40 @@ export function AdminDashboardComponent() {
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link to={`/articles/${article.id}` as any}>Уреди</Link> <Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to={`/articles/${article.id}` as any} target="_blank"> <Link to="/articles/$id" params={{ id: article.id }} target="_blank">
Преглед Преглед
</Link> </Link>
</Button> </Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('article', article.id, article.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -219,7 +377,8 @@ export function AdminDashboardComponent() {
</Card> </Card>
</div> </div>
{/* Quick Stats */} {/* Quick Stats - Only show when not viewing archived items */}
{!showArchived && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
@@ -255,6 +414,37 @@ export function AdminDashboardComponent() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3>
<p className="text-muted-foreground mb-4">
{dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи
</Button>
<Button
variant={dialogType === 'delete' ? 'destructive' : 'default'}
onClick={handleConfirmAction}
disabled={isProcessing}
>
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -1,6 +1,12 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
import { CommentSection } from '@/components/features/comments/CommentSection'
import { ReactionButtons } from '@/components/features/comments/ReactionButtons'
export function ArticleDetailComponent({ id }: { id: string }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -65,16 +71,101 @@ export function ArticleDetailComponent({ id }: { id: string }) {
)} )}
</div> </div>
{data.featuredImage && ( {data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${
data.imagePosition === 'top'
? 'w-full mb-8'
: data.imagePosition === 'left'
? 'float-none md:float-left mr-0 md:mr-6'
: 'float-none md:float-right ml-0 md:ml-6'
}`}>
<img <img
src={data.featuredImage} src={data.featuredImage}
alt={data.title} alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8" className={`rounded-xl object-cover ${
data.imagePosition === 'top'
? data.imageSize === 'small'
? 'h-32'
: data.imageSize === 'medium'
? 'h-48'
: 'h-64 md:h-96'
: data.imageSize === 'small'
? 'w-full md:w-48 h-32'
: data.imageSize === 'medium'
? 'w-full md:w-64 h-48'
: 'w-full md:w-96 h-64'
}`}
onError={(e) => {
console.error('Failed to load image:', data.featuredImage, e);
e.currentTarget.style.display = 'none';
// Show fallback
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/> />
<div
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
style={{ display: 'none' }}
>
<span>Image not available</span>
</div>
</div>
)}
{/* Video rendering */}
{data.videoUrl && data.videoPosition !== 'none' && (
<div className={getVideoPositionClasses(data.videoPosition)}>
<YouTubeEmbed
url={data.videoUrl}
title={data.title}
caption={data.videoCaption}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
)} )}
<div className="prose prose-slate max-w-none"> <div className="prose prose-slate max-w-none">
<p className="text-lg leading-relaxed mb-6">{data.content}</p> <div className="text-lg leading-relaxed mb-6">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: (props) => (
<img
{...props}
className="max-w-full h-auto rounded-lg my-4"
alt={props.alt || 'Article image'}
/>
),
a: (props) => {
// Check if the link is a YouTube URL
const videoId = extractYouTubeVideoId(props.href || '');
if (videoId) {
return (
<div className="my-6">
<YouTubeEmbed
url={props.href || ''}
title={props.title || 'YouTube video'}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
);
}
// Regular link
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
}
}}
>
{data.content}
</ReactMarkdown>
</div>
</div> </div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
@@ -92,6 +183,17 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
</div> </div>
)} )}
{/* Reactions */}
<div className="mt-8 pt-8 border-t">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
<ReactionButtons articleId={data.id} />
</div>
</div>
{/* Comments */}
<CommentSection articleId={data.id} />
</article> </article>
) )
} }
@@ -35,7 +35,7 @@ export function ArticlesComponent() {
{data?.data.map((article) => ( {data?.data.map((article) => (
<Link <Link
key={article.id} key={article.id}
to={`/articles/${article.id}` as any} to={`/articles/${article.id}`}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block" className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
> >
<h2 className="text-xl font-semibold mb-2 line-clamp-2"> <h2 className="text-xl font-semibold mb-2 line-clamp-2">
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { LoginForm } from '../auth/LoginForm';
import { RegisterForm } from '../auth/RegisterForm';
import { useAuth } from '../../contexts/AuthContext';
import { Navigate } from '@tanstack/react-router';
export function AuthPage() {
const { isAuthenticated } = useAuth();
const [isLogin, setIsLogin] = useState(true);
if (isAuthenticated) {
return <Navigate to="/" />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-lg">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">
Welcome to <span className="text-primary">Placebo.mk</span>
</h1>
<p className="text-muted-foreground">
{isLogin
? 'Login to comment, react, and access admin features'
: 'Join our community of sarcastic news enthusiasts'}
</p>
</div>
{isLogin ? (
<LoginForm
onSuccess={() => window.location.href = '/'}
onSwitchToRegister={() => setIsLogin(false)}
/>
) : (
<RegisterForm
onSuccess={() => window.location.href = '/'}
onSwitchToLogin={() => setIsLogin(true)}
/>
)}
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>
By {isLogin ? 'logging in' : 'registering'}, you agree to our{' '}
<a href="#" className="text-primary hover:underline">Terms of Service</a>{' '}
and{' '}
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
</p>
<p className="mt-2">
Need help?{' '}
<a href="mailto:support@placebo.mk" className="text-primary hover:underline">
Contact support
</a>
</p>
</div>
</div>
</div>
);
}
@@ -38,7 +38,7 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => ( {data?.data.map((liveBlog) => (
<Link <Link
key={liveBlog.id} key={liveBlog.id}
to={`/live-blogs/${liveBlog.slug}` as any} to={`/live-blogs/${liveBlog.slug}`}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block" className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { extractYouTubeVideoId, generateYouTubeEmbedUrl } from '@/lib/video-utils';
interface YouTubeEmbedProps {
/** YouTube video URL or video ID */
url: string;
/** Video title for accessibility */
title?: string;
/** Optional caption below the video */
caption?: string;
/** Additional CSS classes */
className?: string;
/** Autoplay the video (muted on mobile) */
autoplay?: boolean;
/** Show video controls */
controls?: boolean;
/** Reduce YouTube branding */
modestbranding?: boolean;
/** Show related videos at the end */
showRelated?: boolean;
/** Start time in seconds */
startTime?: number;
/** End time in seconds */
endTime?: number;
/** Show loading state */
showLoading?: boolean;
}
export function YouTubeEmbed({
url,
title = 'YouTube video',
caption,
className,
autoplay = false,
controls = true,
modestbranding = true,
showRelated = false,
startTime,
endTime,
showLoading = true,
}: YouTubeEmbedProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
// Extract video ID from URL
const videoId = extractYouTubeVideoId(url);
if (!videoId) {
return (
<div className={cn('p-4 bg-destructive/10 border border-destructive/20 rounded-lg', className)}>
<p className="text-destructive text-sm">
Invalid YouTube URL: {url}
</p>
</div>
);
}
// Generate embed URL with options
const embedUrl = generateYouTubeEmbedUrl(videoId, {
autoplay,
controls,
modestbranding,
rel: showRelated,
playsinline: true,
start: startTime,
end: endTime,
});
return (
<div className={cn('w-full', className)}>
{/* Video container with aspect ratio */}
<div className="relative pt-[56.25%] bg-muted rounded-lg overflow-hidden">
{/* Loading state */}
{showLoading && !isLoaded && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
{/* Error state */}
{hasError && (
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 text-center">
<div className="text-destructive mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-destructive font-medium">Video failed to load</p>
<p className="text-muted-foreground text-sm mt-1">
The YouTube video could not be loaded. Please check the URL or try again later.
</p>
</div>
)}
{/* YouTube iframe */}
{!hasError && (
<iframe
className={cn(
'absolute top-0 left-0 w-full h-full border-0 rounded-lg',
!isLoaded && 'opacity-0'
)}
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
)}
</div>
{/* Caption */}
{caption && (
<div className="mt-2 text-sm text-muted-foreground text-center">
{caption}
</div>
)}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import * as api from '../lib/api';
import type { User } from '@/types';
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
hasRole: (role: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const initializeAuth = () => {
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
try {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} catch (error) {
console.error('Failed to parse stored user:', error);
localStorage.removeItem('token');
localStorage.removeItem('user');
}
}
setIsLoading(false);
};
initializeAuth();
}, []);
const login = async (username: string, password: string) => {
setIsLoading(true);
const response = await api.login({ username, password });
localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(response.user));
setToken(response.access_token);
setUser(response.user);
setIsLoading(false);
};
const register = async (username: string, email: string, password: string) => {
setIsLoading(true);
const response = await api.register({ username, email, password });
localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(response.user));
setToken(response.access_token);
setUser(response.user);
setIsLoading(false);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
api.logout();
};
const isAuthenticated = !!user && !!token;
const hasRole = (role: string) => {
if (!user) return false;
return user.role === role;
};
const value = {
user,
token,
isLoading,
login,
register,
logout,
isAuthenticated,
hasRole,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
export interface LiveBlogStreamEvent { export interface LiveBlogStreamEvent {
type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error'; type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error';
@@ -22,7 +22,7 @@ export function useLiveBlogStream(
maxReconnectAttempts: 10, maxReconnectAttempts: 10,
}; };
const mergedOptions = { ...defaultOptions, ...options }; const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null); const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
@@ -124,14 +124,14 @@ export function useLiveBlogStream(
setConnectionError('Connection to live blog lost'); setConnectionError('Connection to live blog lost');
// Attempt reconnection if enabled and within limits // Attempt reconnection if enabled and within limits
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < optionsRef.current.maxReconnectAttempts) { if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < (optionsRef.current.maxReconnectAttempts || 10)) {
const nextAttempt = reconnectAttemptsRef.current + 1; const nextAttempt = reconnectAttemptsRef.current + 1;
setReconnectAttempts(nextAttempt); setReconnectAttempts(nextAttempt);
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts})`); console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
createConnection(); createConnection();
}, optionsRef.current.reconnectInterval); }, optionsRef.current.reconnectInterval || 3000);
} else if (reconnectAttemptsRef.current >= optionsRef.current.maxReconnectAttempts) { } else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
setConnectionError('Failed to reconnect after multiple attempts'); setConnectionError('Failed to reconnect after multiple attempts');
} }
}; };
+392 -30
View File
@@ -4,6 +4,42 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/
console.log('API_BASE_URL:', API_BASE_URL); console.log('API_BASE_URL:', API_BASE_URL);
console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL); console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL);
// Helper function to get auth headers
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('token');
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
// Enhanced fetch wrapper
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
const headers = getAuthHeaders();
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers,
},
});
// Handle 401 unauthorized
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/auth';
}
return response;
}
export interface Article { export interface Article {
id: string; id: string;
title: string; title: string;
@@ -11,6 +47,11 @@ export interface Article {
excerpt: string | null; excerpt: string | null;
slug: string; slug: string;
featuredImage: string; featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
tags: string[]; tags: string[];
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
views: number; views: number;
@@ -45,12 +86,48 @@ export interface FindArticlesParams {
category?: string; category?: string;
author?: string; author?: string;
tag?: string; tag?: string;
status?: 'draft' | 'published' | 'archived'; status?: string;
search?: string; search?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
export interface CreateArticleDto {
title: string;
content: string;
excerpt?: string;
slug?: string;
featuredImage?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
strapiId?: string;
authorId?: string;
categoryId?: string;
imagePosition?: 'top' | 'left' | 'right' | 'none';
imageSize?: 'small' | 'medium' | 'large';
videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string;
}
export interface UpdateArticleDto {
title?: string;
content?: string;
excerpt?: string;
slug?: string;
featuredImage?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
strapiId?: string;
authorId?: string;
categoryId?: string;
imagePosition?: 'top' | 'left' | 'right' | 'none';
imageSize?: 'small' | 'medium' | 'large';
videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string;
}
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> { export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL); console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@@ -69,7 +146,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
const url = `${API_BASE_URL}/articles?${searchParams}`; const url = `${API_BASE_URL}/articles?${searchParams}`;
console.log('Fetching from:', url); console.log('Fetching from:', url);
const response = await fetch(url); const response = await authFetch(url);
console.log('Response status:', response.status, 'ok:', response.ok); console.log('Response status:', response.status, 'ok:', response.ok);
if (!response.ok) { if (!response.ok) {
@@ -82,7 +159,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
} }
export async function fetchArticleBySlug(slug: string): Promise<Article> { export async function fetchArticleBySlug(slug: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/slug/${slug}`); const response = await authFetch(`${API_BASE_URL}/articles/slug/${slug}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch article'); throw new Error('Failed to fetch article');
} }
@@ -90,13 +167,64 @@ export async function fetchArticleBySlug(slug: string): Promise<Article> {
} }
export async function fetchArticleById(id: string): Promise<Article> { export async function fetchArticleById(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`); const response = await authFetch(`${API_BASE_URL}/articles/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch article'); throw new Error('Failed to fetch article');
} }
return response.json(); return response.json();
} }
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
const response = await authFetch(`${API_BASE_URL}/articles`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create article');
}
return response.json();
}
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
method: 'PUT',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update article');
}
return response.json();
}
export async function deleteArticle(id: string): Promise<void> {
const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete article');
}
}
export async function archiveArticle(id: string): Promise<Article> {
const response = await authFetch(`${API_BASE_URL}/articles/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive article');
}
return response.json();
}
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
const response = await authFetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish article');
}
return response.json();
}
// Live Blog Types // Live Blog Types
export interface LiveBlogUpdate { export interface LiveBlogUpdate {
id: string; id: string;
@@ -126,6 +254,12 @@ export interface LiveBlog {
strapiId: string | null; strapiId: string | null;
authorId: string | null; authorId: string | null;
categoryId: string | null; categoryId: string | null;
featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
viewCount: number; viewCount: number;
author?: { author?: {
id: string; id: string;
@@ -162,7 +296,7 @@ export interface LiveBlogUpdatesResponse {
export interface FindLiveBlogsParams { export interface FindLiveBlogsParams {
category?: string; category?: string;
author?: string; author?: string;
status?: 'draft' | 'live' | 'ended' | 'archived'; status?: string;
search?: string; search?: string;
page?: number; page?: number;
limit?: number; limit?: number;
@@ -219,7 +353,7 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
} }
}); });
const response = await fetch(`${API_BASE_URL}/live-blogs?${searchParams}`); const response = await authFetch(`${API_BASE_URL}/live-blogs?${searchParams}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blogs'); throw new Error('Failed to fetch live blogs');
} }
@@ -227,7 +361,7 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
} }
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> { export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/slug/${slug}`); const response = await authFetch(`${API_BASE_URL}/live-blogs/slug/${slug}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blog'); throw new Error('Failed to fetch live blog');
} }
@@ -235,7 +369,7 @@ export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
} }
export async function fetchLiveBlogById(id: string): Promise<LiveBlog> { export async function fetchLiveBlogById(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`); const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blog'); throw new Error('Failed to fetch live blog');
} }
@@ -247,7 +381,7 @@ export async function fetchLiveBlogUpdates(
page = 1, page = 1,
limit = 50 limit = 50
): Promise<LiveBlogUpdatesResponse> { ): Promise<LiveBlogUpdatesResponse> {
const response = await fetch( const response = await authFetch(
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}` `${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
); );
if (!response.ok) { if (!response.ok) {
@@ -257,7 +391,7 @@ export async function fetchLiveBlogUpdates(
} }
export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> { export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/recent`); const response = await authFetch(`${API_BASE_URL}/live-blogs/recent`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch recent live blogs'); throw new Error('Failed to fetch recent live blogs');
} }
@@ -265,7 +399,7 @@ export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
} }
export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> { export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/featured`); const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch pinned live blogs'); throw new Error('Failed to fetch pinned live blogs');
} }
@@ -273,7 +407,7 @@ export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
} }
export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> { export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/active`); const response = await authFetch(`${API_BASE_URL}/live-blogs/active`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch active live blogs'); throw new Error('Failed to fetch active live blogs');
} }
@@ -285,11 +419,8 @@ export async function createLiveBlogUpdate(
liveBlogId: string, liveBlogId: string,
dto: CreateLiveBlogUpdateDto dto: CreateLiveBlogUpdateDto
): Promise<LiveBlogUpdate> { ): Promise<LiveBlogUpdate> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -303,11 +434,8 @@ export async function updateLiveBlogUpdate(
updateId: string, updateId: string,
dto: UpdateLiveBlogUpdateDto dto: UpdateLiveBlogUpdateDto
): Promise<LiveBlogUpdate> { ): Promise<LiveBlogUpdate> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -317,7 +445,7 @@ export async function updateLiveBlogUpdate(
} }
export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> { export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
@@ -326,11 +454,8 @@ export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string)
} }
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> { export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs`, { const response = await authFetch(`${API_BASE_URL}/live-blogs`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -340,11 +465,8 @@ export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog>
} }
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> { export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -354,10 +476,250 @@ export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promis
} }
export async function deleteLiveBlog(id: string): Promise<void> { export async function deleteLiveBlog(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete live blog'); throw new Error('Failed to delete live blog');
} }
} }
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive live blog');
}
return response.json();
}
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish live blog');
}
return response.json();
}
// Auth Types
import type { User, LoginDto, RegisterDto, AuthResponse } from '@/types';
// Auth API Functions
export async function login(dto: LoginDto): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
return response.json();
}
export async function register(dto: RegisterDto): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Registration failed');
}
return response.json();
}
export async function getProfile(): Promise<User> {
const response = await authFetch(`${API_BASE_URL}/users/profile`, {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
return response.json();
}
export async function logout(): Promise<void> {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
// Comment Types
export interface Comment {
id: string;
content: string;
articleId: string | null;
liveBlogId: string | null;
parentCommentId: string | null;
userId: string;
isVisible: boolean;
user?: {
id: string;
username: string;
email: string;
role: string;
};
reactions?: {
likes: number;
dislikes: number;
};
createdAt: string;
updatedAt: string;
}
export interface CreateCommentDto {
content: string;
articleId?: string;
liveBlogId?: string;
parentCommentId?: string;
}
export interface UpdateCommentDto {
content?: string;
isVisible?: boolean;
}
export interface FindCommentsParams {
articleId?: string;
liveBlogId?: string;
parentCommentId?: string;
page?: number;
limit?: number;
}
export interface CommentsResponse {
data: Comment[];
total: number;
page?: number;
limit?: number;
}
export interface ReactionCounts {
likes: number;
dislikes: number;
}
export interface CreateReactionDto {
type: 'like' | 'dislike';
articleId?: string;
liveBlogId?: string;
commentId?: string;
}
// Comment API Functions
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === 'number') {
searchParams.append(key, value.toString());
} else {
searchParams.append(key, String(value));
}
}
});
const response = await authFetch(`${API_BASE_URL}/comments?${searchParams}`);
if (!response.ok) {
throw new Error('Failed to fetch comments');
}
return response.json();
}
export async function createComment(dto: CreateCommentDto): Promise<Comment> {
const response = await authFetch(`${API_BASE_URL}/comments`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create comment');
}
return response.json();
}
export async function updateComment(id: string, dto: UpdateCommentDto): Promise<Comment> {
const response = await authFetch(`${API_BASE_URL}/comments/${id}`, {
method: 'PUT',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update comment');
}
return response.json();
}
export async function deleteComment(id: string): Promise<void> {
const response = await authFetch(`${API_BASE_URL}/comments/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete comment');
}
}
export async function addReaction(dto: CreateReactionDto): Promise<void> {
const response = await authFetch(`${API_BASE_URL}/comments/reactions`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to add reaction');
}
}
export async function getReactionCounts(
articleId?: string,
liveBlogId?: string,
commentId?: string
): Promise<ReactionCounts> {
const searchParams = new URLSearchParams();
if (articleId) searchParams.append('articleId', articleId);
if (liveBlogId) searchParams.append('liveBlogId', liveBlogId);
if (commentId) searchParams.append('commentId', commentId);
const url = `${API_BASE_URL}/comments/reactions/counts${searchParams.toString() ? `?${searchParams}` : ''}`;
const response = await authFetch(url);
if (!response.ok) {
throw new Error('Failed to fetch reaction counts');
}
return response.json();
}
export async function getUserReaction(
articleId?: string,
liveBlogId?: string,
commentId?: string
): Promise<{ type: string | null }> {
const searchParams = new URLSearchParams();
if (articleId) searchParams.append('articleId', articleId);
if (liveBlogId) searchParams.append('liveBlogId', liveBlogId);
if (commentId) searchParams.append('commentId', commentId);
const url = `${API_BASE_URL}/comments/reactions/user${searchParams.toString() ? `?${searchParams}` : ''}`;
const response = await authFetch(url);
if (!response.ok) {
if (response.status === 401) {
return { type: null };
}
throw new Error('Failed to fetch user reaction');
}
return response.json();
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Video utilities for handling YouTube embeds and video URLs
*/
export type VideoPosition = 'top' | 'inline' | 'bottom' | 'none';
export type VideoPlatform = 'youtube' | 'vimeo' | 'dailymotion' | 'twitch' | 'uploaded' | 'external';
/**
* Extract YouTube video ID from various URL formats
* Supported formats:
* - https://www.youtube.com/watch?v=VIDEO_ID
* - https://youtu.be/VIDEO_ID
* - https://www.youtube.com/embed/VIDEO_ID
* - https://www.youtube.com/v/VIDEO_ID
* - https://www.youtube.com/shorts/VIDEO_ID
*/
export function extractYouTubeVideoId(url: string): string | null {
if (!url) return null;
const patterns = [
// Standard watch URL
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
// Just the video ID
/^([a-zA-Z0-9_-]{11})$/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}
/**
* Check if a URL is a valid YouTube URL
*/
export function isValidYouTubeUrl(url: string): boolean {
return extractYouTubeVideoId(url) !== null;
}
/**
* Get YouTube thumbnail URL for a video ID
* Quality options: default, mq (medium), hq (high), sd (standard), maxres (maximum resolution)
*/
export function getYouTubeThumbnail(
videoId: string,
quality: 'default' | 'mq' | 'hq' | 'sd' | 'maxres' = 'hq'
): string {
const qualityMap = {
default: 'default.jpg',
mq: 'mqdefault.jpg',
hq: 'hqdefault.jpg',
sd: 'sddefault.jpg',
maxres: 'maxresdefault.jpg',
};
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}`;
}
/**
* Detect video platform from URL
*/
export function detectVideoPlatform(url: string): VideoPlatform {
if (!url) return 'external';
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('vimeo.com')) return 'vimeo';
if (url.includes('dailymotion.com')) return 'dailymotion';
if (url.includes('twitch.tv')) return 'twitch';
if (url.match(/\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv)$/i)) return 'uploaded';
return 'external';
}
/**
* Generate YouTube embed URL with privacy-enhanced mode and parameters
*/
export function generateYouTubeEmbedUrl(
videoId: string,
options: {
autoplay?: boolean;
controls?: boolean;
modestbranding?: boolean;
rel?: boolean;
playsinline?: boolean;
start?: number;
end?: number;
} = {}
): string {
const {
autoplay = false,
controls = true,
modestbranding = true,
rel = false,
playsinline = true,
start,
end,
} = options;
const params = new URLSearchParams({
autoplay: autoplay ? '1' : '0',
controls: controls ? '1' : '0',
modestbranding: modestbranding ? '1' : '0',
rel: rel ? '1' : '0',
playsinline: playsinline ? '1' : '0',
});
if (start !== undefined) params.set('start', start.toString());
if (end !== undefined) params.set('end', end.toString());
// Use privacy-enhanced mode (youtube-nocookie.com)
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
}
/**
* Get CSS classes for video positioning
*/
export function getVideoPositionClasses(position: VideoPosition): string {
switch (position) {
case 'top':
return 'mb-8';
case 'inline':
return 'my-6';
case 'bottom':
return 'mt-8';
case 'none':
return '';
default:
return 'my-6';
}
}
/**
* Validate video URL
*/
export function validateVideoUrl(url: string): { isValid: boolean; platform: VideoPlatform; error?: string } {
if (!url) {
return { isValid: false, platform: 'external', error: 'Video URL is required' };
}
try {
new URL(url);
} catch {
return { isValid: false, platform: 'external', error: 'Invalid URL format' };
}
const platform = detectVideoPlatform(url);
if (platform === 'youtube' && !isValidYouTubeUrl(url)) {
return { isValid: false, platform: 'youtube', error: 'Invalid YouTube URL' };
}
return { isValid: true, platform };
}
+3
View File
@@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router' import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext'
const queryClient = new QueryClient() const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
) )
+66 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api'; import * as api from '../lib/api';
export function useArticles(params: api.FindArticlesParams = {}) { export function useArticles(params: api.FindArticlesParams = {}) {
@@ -23,3 +23,68 @@ export function useArticleById(id: string) {
enabled: !!id, enabled: !!id,
}); });
} }
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createArticle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
}
export function useUpdateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) =>
api.updateArticle(id, dto),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
export function useDeleteArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteArticle,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
// Also invalidate any specific article queries
queryClient.removeQueries({ queryKey: ['article', variables] });
},
});
}
export function useArchiveArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.archiveArticle,
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
export function usePublishArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) =>
api.publishArticle(id, status),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
+49
View File
@@ -0,0 +1,49 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function useLogin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
api.login({ username, password }),
onSuccess: (data) => {
queryClient.setQueryData(['auth', 'user'], data.user);
queryClient.setQueryData(['auth', 'token'], data.access_token);
},
});
}
export function useRegister() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ username, email, password }: { username: string; email: string; password: string }) =>
api.register({ username, email, password }),
onSuccess: (data) => {
queryClient.setQueryData(['auth', 'user'], data.user);
queryClient.setQueryData(['auth', 'token'], data.access_token);
},
});
}
export function useProfile() {
return useQuery({
queryKey: ['auth', 'user'],
queryFn: api.getProfile,
enabled: false, // We'll manually trigger this when needed
retry: false,
});
}
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.logout,
onSuccess: () => {
queryClient.removeQueries({ queryKey: ['auth'] });
queryClient.clear();
},
});
}
+98
View File
@@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function useComments(params: api.FindCommentsParams = {}) {
return useQuery({
queryKey: ['comments', params],
queryFn: () => api.fetchComments(params),
});
}
export function useCreateComment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createComment,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ['comments', { articleId: variables.articleId }]
});
queryClient.invalidateQueries({
queryKey: ['comments', { liveBlogId: variables.liveBlogId }]
});
},
});
}
export function useUpdateComment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateCommentDto }) =>
api.updateComment(id, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments'] });
},
});
}
export function useDeleteComment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteComment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments'] });
},
});
}
export function useReactionCounts(
articleId?: string,
liveBlogId?: string,
commentId?: string
) {
return useQuery({
queryKey: ['reactions', 'counts', { articleId, liveBlogId, commentId }],
queryFn: () => api.getReactionCounts(articleId, liveBlogId, commentId),
enabled: !!articleId || !!liveBlogId || !!commentId,
});
}
export function useUserReaction(
articleId?: string,
liveBlogId?: string,
commentId?: string
) {
return useQuery({
queryKey: ['reactions', 'user', { articleId, liveBlogId, commentId }],
queryFn: () => api.getUserReaction(articleId, liveBlogId, commentId),
enabled: !!articleId || !!liveBlogId || !!commentId,
retry: false,
});
}
export function useAddReaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.addReaction,
onSuccess: (_, variables) => {
// Invalidate both counts and user reaction queries
queryClient.invalidateQueries({
queryKey: ['reactions', 'counts', {
articleId: variables.articleId,
liveBlogId: variables.liveBlogId,
commentId: variables.commentId
}]
});
queryClient.invalidateQueries({
queryKey: ['reactions', 'user', {
articleId: variables.articleId,
liveBlogId: variables.liveBlogId,
commentId: variables.commentId
}]
});
},
});
}
+27
View File
@@ -156,3 +156,30 @@ export function useDeleteLiveBlog() {
}, },
}); });
} }
export function useArchiveLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.archiveLiveBlog(id),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
export function usePublishLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) =>
api.publishLiveBlog(id, status),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
+26 -27
View File
@@ -7,8 +7,11 @@ import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailCompo
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent' import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { AuthPage } from './components/routes/AuthPage'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar' import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { Header } from './components/layout/Header'
import './styles.css' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@@ -22,30 +25,7 @@ const rootRoute = createRootRoute({
}), }),
component: () => ( component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col"> <div className="min-h-screen bg-background text-foreground flex flex-col">
<header className="border-b"> <Header />
<div className="container mx-auto max-w-6xl px-4 py-4">
<h1 className="text-3xl font-bold">
<Link to="/" className="hover:underline">Placebo.mk</Link>
</h1>
<nav className="flex gap-4">
<Link to="/" className="text-sm font-medium hover:underline">
Home
</Link>
<Link to="/articles" className="text-sm font-medium hover:underline">
Articles
</Link>
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
Live
</Link>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
Admin
</Link>
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
+ New Live Blog
</Link>
</nav>
</div>
</header>
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8"> <main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
<Outlet /> <Outlet />
@@ -236,25 +216,43 @@ const liveBlogDetailRoute = createRoute({
}, },
}) })
const authRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth',
component: AuthPage,
})
const liveBlogAdminRoute = createRoute({ const liveBlogAdminRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/admin/live-blogs/$slug', path: '/admin/live-blogs/$slug',
component: () => { component: () => {
const { slug } = liveBlogAdminRoute.useParams() const { slug } = liveBlogAdminRoute.useParams()
return <LiveBlogAdminComponent slug={slug} /> return (
<ProtectedRoute requiredRole="admin">
<LiveBlogAdminComponent slug={slug} />
</ProtectedRoute>
)
}, },
}) })
const createLiveBlogRoute = createRoute({ const createLiveBlogRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/admin/live-blogs/create', path: '/admin/live-blogs/create',
component: CreateLiveBlogComponent, component: () => (
<ProtectedRoute requiredRole="admin">
<CreateLiveBlogComponent />
</ProtectedRoute>
),
}) })
const adminDashboardRoute = createRoute({ const adminDashboardRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/admin', path: '/admin',
component: AdminDashboardComponent, component: () => (
<ProtectedRoute requiredRole="admin">
<AdminDashboardComponent />
</ProtectedRoute>
),
}) })
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
@@ -263,6 +261,7 @@ const routeTree = rootRoute.addChildren([
articleDetailRoute, articleDetailRoute,
liveBlogsRoute, liveBlogsRoute,
liveBlogDetailRoute, liveBlogDetailRoute,
authRoute,
liveBlogAdminRoute, liveBlogAdminRoute,
createLiveBlogRoute, createLiveBlogRoute,
adminDashboardRoute, adminDashboardRoute,
+32
View File
@@ -0,0 +1,32 @@
export type UserRole = 'admin' | 'contributor' | 'user';
export interface User {
id: string;
username: string;
email: string;
role: UserRole;
createdAt: string;
updatedAt: string;
}
export interface LoginDto {
username: string;
password: string;
}
export interface RegisterDto {
username: string;
email: string;
password: string;
}
export interface AuthResponse {
access_token: string;
user: User;
}
export interface ApiError {
message: string;
statusCode: number;
error?: string;
}
+1
View File
@@ -0,0 +1 @@
export type { User, UserRole, LoginDto, RegisterDto, AuthResponse, ApiError } from './auth';
+6
View File
@@ -13,6 +13,12 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
// For npm workspaces - look for dependencies in parent node_modules
preserveSymlinks: true,
},
// For npm workspaces - optimize dependencies from root
optimizeDeps: {
include: ['react', 'react-dom', 'react-markdown'],
}, },
server: { server: {
host: true, // Listen on all addresses host: true, // Listen on all addresses
+2183 -1733
View File
File diff suppressed because it is too large. Load diff
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# Test script for Strapi webhooks
# Usage: ./test-webhooks.sh [article-id]
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}=== Strapi Webhook Test Script ===${NC}\n"
# Default article ID (first article from Strapi)
DEFAULT_ARTICLE_ID="r07qatlpgvx82d7337n3nz1l"
ARTICLE_ID=${1:-$DEFAULT_ARTICLE_ID}
echo "Testing with article ID: $ARTICLE_ID"
echo ""
# Test 1: Check backend is running
echo -e "${YELLOW}1. Testing backend connectivity...${NC}"
if curl -s -f "http://localhost:3000/api/v1/articles" > /dev/null; then
echo -e "${GREEN}✓ Backend is running${NC}"
else
echo -e "${RED}✗ Backend is not accessible${NC}"
exit 1
fi
# Test 2: Check Strapi is running
echo -e "${YELLOW}2. Testing Strapi connectivity...${NC}"
if curl -s -f "http://localhost:1337/_health" > /dev/null 2>&1 || curl -s -f "http://localhost:1337/admin" > /dev/null; then
echo -e "${GREEN}✓ Strapi is running${NC}"
else
echo -e "${RED}✗ Strapi is not accessible${NC}"
exit 1
fi
# Test 3: Send test webhook
echo -e "${YELLOW}3. Sending test webhook...${NC}"
WEBHOOK_RESPONSE=$(curl -s -w "%{http_code}" -X POST "http://localhost:3000/api/v1/webhooks/strapi/article" \
-H "Content-Type: application/json" \
-d "{
\"event\": \"entry.publish\",
\"model\": \"article\",
\"entry\": {
\"documentId\": \"$ARTICLE_ID\"
}
}")
# Extract status code and body
HTTP_STATUS=${WEBHOOK_RESPONSE: -3}
RESPONSE_BODY=${WEBHOOK_RESPONSE%???}
if [[ "$HTTP_STATUS" =~ ^2[0-9][0-9]$ ]]; then
echo -e "${GREEN}✓ Webhook sent successfully (HTTP $HTTP_STATUS)${NC}"
echo "Response: $RESPONSE_BODY"
else
echo -e "${RED}✗ Webhook failed (HTTP $HTTP_STATUS)${NC}"
echo "Response: $RESPONSE_BODY"
exit 1
fi
# Test 4: Check if article is in backend
echo -e "${YELLOW}4. Verifying article in backend...${NC}"
sleep 2 # Give backend time to process
ARTICLE_COUNT=$(curl -s "http://localhost:3000/api/v1/articles?status=published" | jq '.total // 0')
if [ "$ARTICLE_COUNT" -gt 0 ]; then
echo -e "${GREEN}✓ Backend has $ARTICLE_COUNT published article(s)${NC}"
# List articles
echo -e "\n${YELLOW}Articles in backend:${NC}"
curl -s "http://localhost:3000/api/v1/articles?status=published" | jq -r '.data[] | "• \(.title) (ID: \(.id))"'
else
echo -e "${RED}✗ No articles found in backend${NC}"
fi
# Test 5: Check Strapi articles
echo -e "\n${YELLOW}5. Checking Strapi articles...${NC}"
STRAPI_TOKEN="578d628f62df967ff95f95bedb205b5d10bbf792340519c8c467d6473208e16b3918151a97b49fa2285a53df0ec8e340a9ca555b01a654bd22152847840e6a368ee626a6f1338ce2f23790c171013b263ec80fbaf116e2b459d3663b234d08855fd0eb631991ed15bb94f7dbb0b80f190352965c72c7fd327c73629ceff38fbb"
STRAPI_ARTICLES=$(curl -s -H "Authorization: Bearer $STRAPI_TOKEN" "http://localhost:1337/api/articles")
STRAPI_COUNT=$(echo "$STRAPI_ARTICLES" | jq '.data | length // 0')
if [ "$STRAPI_COUNT" -gt 0 ]; then
echo -e "${GREEN}✓ Strapi has $STRAPI_COUNT article(s)${NC}"
echo -e "\n${YELLOW}Articles in Strapi:${NC}"
echo "$STRAPI_ARTICLES" | jq -r '.data[] | "• \(.title) (Strapi ID: \(.documentId))"'
else
echo -e "${RED}✗ No articles found in Strapi${NC}"
fi
echo -e "\n${YELLOW}=== Summary ===${NC}"
echo "Backend articles: $ARTICLE_COUNT"
echo "Strapi articles: $STRAPI_COUNT"
if [ "$ARTICLE_COUNT" -eq "$STRAPI_COUNT" ]; then
echo -e "${GREEN}✓ Sync appears to be working correctly!${NC}"
else
echo -e "${YELLOW}⚠ Article counts don't match. Manual sync may be needed.${NC}"
echo "Run: curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all -H \"Authorization: Bearer YOUR_TOKEN\""
fi
echo -e "\n${YELLOW}Next steps:${NC}"
echo "1. Configure webhooks in Strapi admin: http://localhost:1337/admin"
echo "2. Test by publishing an article in Strapi"
echo "3. Check backend logs: docker logs placebo-backend-dev --tail 20"
echo "4. Visit frontend: http://localhost:5173/articles"
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Article API</title>
<script>
async function testAPI() {
try {
const response = await fetch('http://localhost:3000/api/v1/articles?status=published');
const data = await response.json();
document.getElementById('result').innerHTML = `
<h2>API Test Result</h2>
<p>Status: ${response.status}</p>
<p>Articles found: ${data.total}</p>
<pre>${JSON.stringify(data.data[0], null, 2)}</pre>
`;
} catch (error) {
document.getElementById('result').innerHTML = `
<h2>Error</h2>
<p>${error.message}</p>
`;
}
}
window.onload = testAPI;
</script>
</head>
<body>
<h1>Testing Article API</h1>
<div id="result">Loading...</div>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
1. role based auth [admin, contributor, user]
3. social media integration [telegram, whatsup, facebook, viber]
4. share with functionality
lets implement a role based auth
admin can create, edit and delete articles and liveblogs
admin can create contributors
contributors can create, edit and delete articles and liveblogs
user can react(like, dislike) to articles and write comments