19 Commits
Author SHA1 Message Date
dimitar 46eb41aaa5 category field added
implemented ctegory logic, ditional pages added
2026-02-06 03:35:28 +01:00
dimitar add12b2fbf hero section added
hero article menagment implemented in admin UI
2026-02-06 02:14:10 +01:00
dimitar 6d65d5975c mobile nav implemented
dark mode need rifinements
2026-02-06 01:04:43 +01:00
dimitar b8779e5a35 social share up and running 2026-02-05 03:04:19 +01:00
dimitar 000ebd388a article detail page @public 2026-02-05 00:10:23 +01:00
dimitar 3374eb1ec0 reply to comment implemented 2026-02-04 23:51:37 +01:00
dimitar d7b82c0ec9 comment func implemented 2026-02-04 23:08:47 +01:00
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
dimitar 7d3bc2a014 setup completed 2026-02-03 17:45:38 +01:00
dimitar acfbc80d1a unpublish fix 2026-02-01 13:57:16 +01:00
113 changed files with 41002 additions and 958 deletions

No files matched your search

+84 -17
View File
@@ -8,39 +8,75 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
- **Backend**: NestJS + TypeORM + SQLite
- **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
### Backend (NestJS)
```bash
cd backend
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 start:prod
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check
npm test # All tests
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check
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 -t "should" # By test name
npm run test:cov # Coverage
npm run test:e2e # E2E tests
npm test -t "should return" # By test name pattern
npm run format # Format with Prettier
npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
```
### Frontend (TanStack)
```bash
cd frontend
npm install
npm run dev # Vite dev server
npm run dev # Vite dev server
npm run build
npm run preview
npm run lint
npm run lint:fix
npm run type-check
npm test # All tests
npm test Header.test.tsx # Single file
npm test -t "renders" # By name
npm run test:ui # Vitest UI
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check
npm test # All tests (Vitest)
npm test:ui # Vitest UI
npm test:coverage # Test coverage
npm test Header.test.tsx # Single file
npm test -t "renders" # By test name pattern
npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
```
## Code Style
@@ -51,6 +87,8 @@ npm run test:ui # Vitest UI
- Explicit return types for public methods
- Prefer interfaces over types for objects
- Use `readonly` for immutable properties
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
- Frontend: Path alias `@/*` maps to `./src/*`
### Naming Conventions
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
@@ -62,6 +100,20 @@ npm run test:ui # Vitest UI
### Import Order
- 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
@@ -104,12 +156,16 @@ frontend/src/
```
### Testing
- **Backend**: Jest with ts-jest transformer
- **Frontend**: Vitest with React Testing Library patterns
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
- Integration tests for API endpoints
- 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)
- 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
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
@@ -147,5 +203,16 @@ frontend/src/
- Example backend env: `DATABASE_PATH=./data.db`
- Store sensitive config in `.env` files
- 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`
+188
View File
@@ -0,0 +1,188 @@
# Placebo.mk - Development Setup
This project supports two development modes:
1. **Docker Mode** (Recommended): All services run in Docker containers
2. **Local Mode**: Services run locally with npm, PostgreSQL in Docker
## Quick Start
### Option 1: Docker Mode (Recommended)
```bash
# Start all services in Docker
npm run docker:up
# Or use the alias
npm run dev:docker
```
Services will be available at:
- Frontend: http://localhost:5173
- Backend API: http://localhost:3000
- CMS (Strapi): http://localhost:1337/admin
- PostgreSQL: localhost:5432
### Option 2: Local Mode
```bash
# Start PostgreSQL in Docker
docker-compose -f docker-compose.dev.yml up postgres
# In another terminal, start backend and frontend locally
npm run dev:local
```
## Environment Configuration
### Backend Configuration
The backend uses `.env` files for configuration:
- `.env` - Default Docker configuration (committed)
- `.env.local` - Local development configuration (not committed)
- `.env.docker` - Docker configuration template
- `.env.example` - Example configuration
**Switching between modes:**
```bash
# Switch to local mode
cd backend && npm run dev:local
# Switch back to Docker mode
cd backend && npm run dev:reset-env
```
### Frontend Configuration
The frontend also uses `.env` files:
- `.env` - Default Docker configuration (committed)
- `.env.local` - Local development configuration (not committed)
- `.env.docker` - Docker configuration template
**API URL differences:**
- Docker: `VITE_API_URL=http://backend:3000/api/v1`
- Local: `VITE_API_URL=http://localhost:3000/api/v1`
## Development Scripts
### Root Level Scripts
```bash
# Docker commands
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 docker:ps # Check container status
# Development modes
npm run dev:docker # Docker mode (alias for docker:up)
npm run dev:local # Local mode
# Utility
npm run reset:env # Reset .env files to Docker defaults
npm run lint # Run linting on all services
npm run type-check # Run TypeScript checks
```
### Backend Scripts (in backend/ directory)
```bash
npm run dev:docker # Start in Docker mode (uses .env)
npm run dev:local # Start in local mode (uses .env.local)
npm run start:dev # Original NestJS dev command
npm run dev:reset-env # Reset .env to Docker defaults
```
### Frontend Scripts (in frontend/ directory)
```bash
npm run dev:docker # Start in Docker mode
npm run dev:local # Start in local mode
npm run dev # Original Vite dev command
npm run dev:reset-env # Reset .env to Docker defaults
```
## Database Management
### PostgreSQL in Docker
The PostgreSQL container is configured with:
- Host: `localhost:5432` (from host) or `postgres:5432` (from Docker)
- Database: `placebo_backend_db` (backend), `placebo_cms_db` (CMS)
- User: `placebo_user`
- Password: `placebo_password`
### Connecting to Database
```bash
# From host machine
psql -h localhost -p 5432 -U placebo_user -d placebo_backend_db
# From within Docker container
psql -h postgres -p 5432 -U placebo_user -d placebo_backend_db
```
## Project Structure
```
placeboMk/
├── backend/ # NestJS backend API
│ ├── .env # Docker configuration (default)
│ ├── .env.local # Local development configuration
│ └── src/
├── frontend/ # React/Vite frontend
│ ├── .env # Docker configuration (default)
│ ├── .env.local # Local development configuration
│ └── src/
├── cms/cms/ # Strapi CMS
├── docker-compose.yml # Production configuration
├── docker-compose.dev.yml # Development configuration
└── package.json # Root scripts
```
## Troubleshooting
### Common Issues
1. **Port conflicts**
- Check if ports 3000, 5173, 1337, 5432 are free
- Use `lsof -i :3000` to check what's using a port
2. **Database connection errors**
- Ensure PostgreSQL container is running: `docker ps`
- Check logs: `docker logs placebo-postgres-dev`
- Verify credentials in `.env` files
3. **Environment configuration**
- Run `npm run reset:env` to reset to Docker defaults
- Ensure `.env.local` exists for local development
4. **Docker container issues**
- Rebuild containers: `npm run docker:build`
- Clear Docker cache: `docker system prune -f`
### Reset Everything
```bash
# Stop and remove containers
npm run docker:down
docker system prune -f
# Reset environment files
npm run reset:env
# Start fresh
npm run docker:up
```
## Production Deployment
For production, use:
```bash
docker-compose -f docker-compose.yml up -d
```
Production configuration:
- Uses separate `docker-compose.yml`
- Includes Nginx reverse proxy
- No hot reload, optimized builds
- Health checks and proper logging
## Notes
- **Docker-first approach**: `.env` files are configured for Docker by default
- **Local development**: Uses `.env.local` files when running services locally
- **PostgreSQL**: Always runs in Docker for consistency
- **Secrets**: Never commit actual secrets to repository
+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
+31 -4
View File
@@ -1,7 +1,34 @@
PORT=3000
DATABASE_PATH=./database.sqlite
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# Environment Configuration Template
# Copy to .env and adjust values for your setup
# ===== DOCKER MODE (services in containers) =====
# Use these values when running in Docker Compose
# DATABASE_HOST=postgres
# STRAPI_URL=http://cms:1337
# ===== LOCAL MODE (backend locally, DB in Docker) =====
# Use these values when running backend locally with npm run dev
DATABASE_HOST=localhost
STRAPI_URL=http://localhost:1337
# ===== COMMON CONFIGURATION =====
NODE_ENV=development
PORT=3000
# Database Configuration
DATABASE_PORT=5432
DATABASE_USERNAME=placebo_user
DATABASE_PASSWORD=placebo_password
DATABASE_NAME=placebo_backend_db
DATABASE_SYNCHRONIZE=true
DATABASE_LOGGING=true
# JWT Configuration
JWT_SECRET=dev-jwt-secret-change-in-production
JWT_EXPIRATION=3600
# CORS Configuration
CORS_ORIGIN=http://localhost:5173
# Strapi CMS Configuration
STRAPI_API_TOKEN=your-strapi-api-token-here
+3
View File
@@ -4,6 +4,9 @@ FROM node:20-alpine
WORKDIR /app
# Install build dependencies for native modules
RUN apk add --no-cache python3 make g++
# Install dependencies with better error handling
COPY package*.json ./
COPY package-lock.json* ./
+638 -342
View File
File diff suppressed because it is too large. Load diff
+15 -1
View File
@@ -19,7 +19,12 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts",
"seed:categories": "ts-node scripts/seed-categories.ts"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
@@ -27,10 +32,19 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.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/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-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"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();
+65
View File
@@ -0,0 +1,65 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Category } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
try {
const categoryRepository = app.get(getRepositoryToken(Category));
// Define categories with Macedonian names and English slugs
const categories = [
{
name: 'Спорт',
slug: 'sport',
description: 'Спортски вести и анализи',
order: 1,
},
{
name: 'Уметност',
slug: 'art',
description: 'Уметност, култура и забава',
order: 2,
},
{
name: 'Наука',
slug: 'science',
description: 'Научни откритија и технологија',
order: 3,
},
];
console.log('Seeding categories...');
for (const categoryData of categories) {
// Check if category already exists
const existingCategory = await categoryRepository.findOne({
where: { slug: categoryData.slug },
});
if (existingCategory) {
console.log(`Category "${categoryData.name}" (${categoryData.slug}) already exists`);
} else {
const category = categoryRepository.create(categoryData);
await categoryRepository.save(category);
console.log(`Created category: "${categoryData.name}" (${categoryData.slug})`);
}
}
console.log('\n✅ Category seeding completed!');
console.log('Categories available:');
const allCategories = await categoryRepository.find({ order: { order: 'ASC' } });
allCategories.forEach((cat: Category) => {
console.log(` • ${cat.name} (${cat.slug}) - ${cat.description || 'No description'}`);
});
} catch (error) {
console.error('Error seeding categories:', error);
} finally {
await app.close();
}
}
bootstrap();
+25 -3
View File
@@ -6,13 +6,21 @@ import { AppService } from './app.service';
import { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.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 { AnalyticsModule } from './modules/analytics/analytics.module';
import {
Article,
Author,
Category,
LiveBlog,
LiveBlogUpdate,
User,
Comment,
Reaction,
} from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity';
@Module({
imports: [
@@ -26,13 +34,27 @@ import {
username: process.env.DATABASE_USERNAME || 'placebo_user',
password: process.env.DATABASE_PASSWORD || 'placebo_password',
database: process.env.DATABASE_NAME || 'placebo_backend_db',
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
entities: [
Article,
Author,
Category,
LiveBlog,
LiveBlogUpdate,
User,
Comment,
Reaction,
ShareEvent,
],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true',
}),
ArticlesModule,
StrapiModule,
LiveBlogModule,
UserModule,
AuthModule,
CommentModule,
AnalyticsModule,
],
controllers: [AppController],
providers: [AppService],
+18 -3
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 { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -15,11 +17,24 @@ async function bootstrap() {
});
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 host = '0.0.0.0'; // Bind to all interfaces for Docker
await app.listen(port, host);
console.log(`Application is running on: http://${host}:${port}`);
}
void bootstrap();
@@ -0,0 +1,76 @@
import {
Controller,
Post,
Body,
Get,
Query,
UsePipes,
ValidationPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import {
TrackShareDto,
GetShareStatsDto,
ShareStatsResponse,
} from './analytics.dto';
import { Public } from '../auth/public.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../entities';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Public()
@Post('share')
@HttpCode(HttpStatus.CREATED)
@UsePipes(new ValidationPipe({ transform: true }))
async trackShare(@Body() trackShareDto: TrackShareDto) {
return await this.analyticsService.trackShare(trackShareDto);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares')
@UsePipes(new ValidationPipe({ transform: true }))
async getShareStats(
@Query() getShareStatsDto: GetShareStatsDto,
): Promise<ShareStatsResponse[]> {
return await this.analyticsService.getShareStats(getShareStatsDto);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/top')
async getTopSharedArticles(@Query('limit') limit: string) {
const limitNum = limit ? parseInt(limit) : 10;
return await this.analyticsService.getTopSharedArticles(limitNum);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/trends')
async getShareTrends(
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
@Query('interval') interval: 'day' | 'week' | 'month',
) {
return await this.analyticsService.getShareTrends(
new Date(startDate),
new Date(endDate),
interval || 'day',
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('shares/total')
async getTotalShareStats() {
return await this.analyticsService.getTotalShareStats();
}
}
@@ -0,0 +1,47 @@
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import type { SharePlatform } from './analytics.entity';
export class TrackShareDto {
@IsUUID()
articleId: string;
@IsEnum(['facebook', 'twitter', 'whatsapp', 'telegram', 'link'])
platform: SharePlatform;
@IsOptional()
@IsString()
userAgent?: string;
@IsOptional()
@IsString()
ipAddress?: string;
}
export class GetShareStatsDto {
@IsOptional()
@IsUUID()
articleId?: string;
@IsOptional()
@IsString()
startDate?: string;
@IsOptional()
@IsString()
endDate?: string;
}
export class ShareStatsResponse {
articleId: string;
articleTitle: string;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
totalShares: number;
views: number;
shareRate: number;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Article } from '../entities';
export type SharePlatform =
| 'facebook'
| 'twitter'
| 'whatsapp'
| 'telegram'
| 'link';
@Entity('share_events')
export class ShareEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
articleId: string;
@Column({ type: 'text' })
platform: SharePlatform;
@Column({ nullable: true })
userId: string;
@Column({ nullable: true })
userAgent: string;
@Column({ nullable: true })
ipAddress: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
}
export interface ShareStats {
articleId: string;
articleTitle: string;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
totalShares: number;
views: number;
shareRate: number;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { ShareEvent } from './analytics.entity';
import { Article } from '../entities';
@Module({
imports: [TypeOrmModule.forFeature([ShareEvent, Article])],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -0,0 +1,266 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ShareEvent, SharePlatform } from './analytics.entity';
import { Article } from '../entities';
import {
TrackShareDto,
GetShareStatsDto,
ShareStatsResponse,
} from './analytics.dto';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(
@InjectRepository(ShareEvent)
private readonly shareEventRepository: Repository<ShareEvent>,
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
) {}
async trackShare(trackShareDto: TrackShareDto): Promise<ShareEvent> {
const shareEvent = this.shareEventRepository.create(trackShareDto);
// Also update the article's share counters
await this.incrementArticleShareCounter(
trackShareDto.articleId,
trackShareDto.platform,
);
return await this.shareEventRepository.save(shareEvent);
}
private async incrementArticleShareCounter(
articleId: string,
platform: SharePlatform,
): Promise<void> {
const updateField = this.getShareCounterField(platform);
if (!updateField) return;
await this.articleRepository
.createQueryBuilder()
.update(Article)
.set({ [updateField]: () => `${updateField} + 1` })
.where('id = :id', { id: articleId })
.execute();
}
private getShareCounterField(platform: SharePlatform): string | null {
switch (platform) {
case 'facebook':
return 'facebookShares';
case 'twitter':
return 'twitterShares';
case 'whatsapp':
return 'whatsappShares';
case 'telegram':
return 'telegramShares';
default:
return null; // 'link' shares don't increment counters
}
}
async getShareStats(
getShareStatsDto: GetShareStatsDto,
): Promise<ShareStatsResponse[]> {
const query = this.articleRepository
.createQueryBuilder('article')
.select([
'article.id as "articleId"',
'article.title as "articleTitle"',
'article.facebookShares as "facebookShares"',
'article.twitterShares as "twitterShares"',
'article.whatsappShares as "whatsappShares"',
'article.telegramShares as "telegramShares"',
'article.views as "views"',
'article.createdAt as "createdAt"',
'article.updatedAt as "updatedAt"',
])
.addSelect(
`(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares) as "totalShares"`,
)
.addSelect(
`CASE
WHEN article.views > 0
THEN ROUND(
(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares)::decimal / article.views * 100,
2
)
ELSE 0
END as "shareRate"`,
);
if (getShareStatsDto.articleId) {
query.where('article.id = :articleId', {
articleId: getShareStatsDto.articleId,
});
}
if (getShareStatsDto.startDate) {
query.andWhere('article.createdAt >= :startDate', {
startDate: getShareStatsDto.startDate,
});
}
if (getShareStatsDto.endDate) {
query.andWhere('article.createdAt <= :endDate', {
endDate: getShareStatsDto.endDate,
});
}
query.orderBy('"totalShares"', 'DESC');
const rawResults = await query.getRawMany<{
articleId: string;
articleTitle: string;
facebookShares: string;
twitterShares: string;
whatsappShares: string;
telegramShares: string;
views: string;
createdAt: string;
updatedAt: string;
totalShares: string;
shareRate: string;
}>();
// Get link shares from share_events table
const results: ShareStatsResponse[] = [];
for (const rawResult of rawResults) {
const linkShares = await this.shareEventRepository.count({
where: {
articleId: rawResult.articleId,
platform: 'link',
},
});
const facebookShares = parseInt(rawResult.facebookShares) || 0;
const twitterShares = parseInt(rawResult.twitterShares) || 0;
const whatsappShares = parseInt(rawResult.whatsappShares) || 0;
const telegramShares = parseInt(rawResult.telegramShares) || 0;
const views = parseInt(rawResult.views) || 0;
const baseTotalShares =
facebookShares + twitterShares + whatsappShares + telegramShares;
const totalShares = baseTotalShares + linkShares;
const shareRate =
views > 0 ? parseFloat(((totalShares / views) * 100).toFixed(2)) : 0;
results.push({
articleId: rawResult.articleId,
articleTitle: rawResult.articleTitle,
facebookShares,
twitterShares,
whatsappShares,
telegramShares,
linkShares,
totalShares,
views,
shareRate,
createdAt: new Date(rawResult.createdAt),
updatedAt: new Date(rawResult.updatedAt),
});
}
return results;
}
async getTopSharedArticles(
limit: number = 10,
): Promise<ShareStatsResponse[]> {
const stats = await this.getShareStats({});
return stats.slice(0, limit);
}
async getShareTrends(
startDate: Date,
endDate: Date,
interval: 'day' | 'week' | 'month' = 'day',
): Promise<
Array<{ period: string; platform: SharePlatform; count: number }>
> {
const dateFormat =
interval === 'day'
? 'YYYY-MM-DD'
: interval === 'week'
? 'YYYY-WW'
: 'YYYY-MM';
const query = this.shareEventRepository
.createQueryBuilder('share_event')
.select([
`TO_CHAR(share_event.createdAt, '${dateFormat}') as period`,
'share_event.platform as platform',
'COUNT(*) as count',
])
.where('share_event.createdAt >= :startDate', { startDate })
.andWhere('share_event.createdAt <= :endDate', { endDate })
.groupBy('period, platform')
.orderBy('period', 'ASC');
const rawResults = await query.getRawMany<{
period: string;
platform: string;
count: string;
}>();
// Convert platform strings to SharePlatform type
return rawResults.map((result) => ({
period: result.period,
platform: result.platform as SharePlatform,
count: parseInt(result.count) || 0,
}));
}
async getTotalShareStats(): Promise<{
totalShares: number;
facebookShares: number;
twitterShares: number;
whatsappShares: number;
telegramShares: number;
linkShares: number;
}> {
interface ArticleStatsRaw {
facebookShares: string;
twitterShares: string;
whatsappShares: string;
telegramShares: string;
}
const articleStats = (await this.articleRepository
.createQueryBuilder('article')
.select([
'SUM(article.facebookShares) as facebookShares',
'SUM(article.twitterShares) as twitterShares',
'SUM(article.whatsappShares) as whatsappShares',
'SUM(article.telegramShares) as telegramShares',
])
.getRawOne()) as ArticleStatsRaw;
const linkShares = await this.shareEventRepository.count({
where: { platform: 'link' },
});
const facebookShares = parseInt(articleStats?.facebookShares || '0') || 0;
const twitterShares = parseInt(articleStats?.twitterShares || '0') || 0;
const whatsappShares = parseInt(articleStats?.whatsappShares || '0') || 0;
const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0;
const totalShares =
facebookShares +
twitterShares +
whatsappShares +
telegramShares +
linkShares;
return {
totalShares,
facebookShares,
twitterShares,
whatsappShares,
telegramShares,
linkShares,
};
}
}
+40 -9
View File
@@ -4,10 +4,11 @@ import {
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
ValidationPipe,
UseGuards,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import {
@@ -16,42 +17,72 @@ import {
FindArticlesDto,
} 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')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@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);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
) {
@Public()
findAll(@Query() dto: FindArticlesDto) {
return this.articlesService.findAll(dto);
}
@Get('hero')
@Public()
findHero() {
return this.articlesService.findHeroArticle();
}
@Get(':id')
@Public()
findOne(@Param('id') id: string) {
return this.articlesService.findOne(id);
}
@Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) {
return this.articlesService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
return this.articlesService.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) {
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);
}
}
+193 -48
View File
@@ -6,9 +6,14 @@ import {
IsUUID,
IsNumber,
IsBoolean,
IsDate,
} from 'class-validator';
import { ArticleStatus, LiveBlogStatus } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
@@ -42,6 +47,10 @@ export class CreateArticleDto {
@IsString()
strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@@ -49,6 +58,50 @@ export class CreateArticleDto {
@IsOptional()
@IsUUID()
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;
@IsOptional()
@IsString()
ogTitle?: string;
@IsOptional()
@IsString()
ogDescription?: string;
@IsOptional()
@IsString()
ogImage?: string;
@IsOptional()
@IsString()
twitterTitle?: string;
@IsOptional()
@IsString()
twitterDescription?: string;
@IsOptional()
@IsString()
twitterImage?: string;
}
export class UpdateArticleDto {
@@ -85,6 +138,10 @@ export class UpdateArticleDto {
@IsString()
strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@@ -92,6 +149,50 @@ export class UpdateArticleDto {
@IsOptional()
@IsUUID()
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;
@IsOptional()
@IsString()
ogTitle?: string;
@IsOptional()
@IsString()
ogDescription?: string;
@IsOptional()
@IsString()
ogImage?: string;
@IsOptional()
@IsString()
twitterTitle?: string;
@IsOptional()
@IsString()
twitterDescription?: string;
@IsOptional()
@IsString()
twitterImage?: string;
}
export class FindArticlesDto {
@@ -108,8 +209,37 @@ export class FindArticlesDto {
tag?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
@@ -153,6 +283,30 @@ export class CreateLiveBlogDto {
@IsOptional()
@IsUUID()
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 {
@@ -187,6 +341,30 @@ export class UpdateLiveBlogDto {
@IsOptional()
@IsUUID()
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 {
@@ -194,20 +372,16 @@ export class CreateLiveBlogUpdateDto {
content: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsString()
image?: string;
@IsOptional()
@IsString()
strapiId?: string;
videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
}
export class UpdateLiveBlogUpdateDto {
@@ -216,47 +390,18 @@ export class UpdateLiveBlogUpdateDto {
content?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsString()
image?: string;
@IsOptional()
@IsString()
strapiId?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
videoUrl?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
authorName?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
+356
View File
@@ -0,0 +1,356 @@
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsUUID,
IsNumber,
IsBoolean,
IsDate,
} from 'class-validator';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
title: string;
@IsString()
content: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
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 {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
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 {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class CreateLiveBlogDto {
@IsString()
title: string;
@IsString()
slug: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
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 {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(LiveBlogStatus)
status?: LiveBlogStatus;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
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 {
@IsString()
content: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class UpdateLiveBlogUpdateDto {
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsOptional()
@IsString()
strapiId?: string;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
+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 { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
imports: [
TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule),
],
controllers: [ArticlesController],
providers: [ArticlesService],
exports: [ArticlesService],
+201 -22
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 { Repository } from 'typeorm';
import { Article, ArticleStatus } from './entities';
import { StrapiService } from './strapi.service';
import {
CreateArticleDto,
UpdateArticleDto,
@@ -10,9 +17,13 @@ import {
@Injectable()
export class ArticlesService {
private readonly logger = new Logger(ArticlesService.name);
constructor(
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {}
async create(dto: CreateArticleDto): Promise<Article> {
@@ -26,21 +37,23 @@ export class ArticlesService {
async findAll(
dto: FindArticlesDto,
): Promise<{ data: Article[]; total: number }> {
const {
category,
author,
tag,
status = ArticleStatus.PUBLISHED,
search,
page = 1,
limit = 10,
} = dto;
const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
const queryBuilder = this.articleRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.category', 'category')
.where('article.status = :status', { status });
.leftJoinAndSelect('article.category', 'category');
// 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) {
queryBuilder.andWhere('category.slug = :category', { category });
@@ -98,31 +111,197 @@ export class ArticlesService {
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
const article = await this.findOne(id);
const oldStatus = article.status;
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> {
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);
}
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(
strapiId: string,
data: Partial<CreateArticleDto>,
): 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) {
// 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,
...data,
status: data.status || ArticleStatus.DRAFT,
});
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(existingArticle);
}
}
// Re-throw other errors
throw error;
}
}
async removeByStrapiId(strapiId: string): Promise<void> {
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) {
article = this.articleRepository.create({
strapiId,
...data,
status: data.status || ArticleStatus.DRAFT,
});
} else {
Object.assign(article, data);
this.logger.warn(`Article with strapiId ${strapiId} not found`);
return;
}
return await this.articleRepository.save(article);
await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
}
async findHeroArticle(): Promise<Article | null> {
return this.articleRepository.findOne({
where: {
isHero: true,
status: ArticleStatus.PUBLISHED,
},
relations: ['author', 'category'],
order: { createdAt: 'DESC' },
});
}
}
@@ -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,153 @@
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')
@Public()
async getUserReaction(
@Request() req: RequestWithUser,
@Query('articleId') articleId?: string,
@Query('liveBlogId') liveBlogId?: string,
@Query('commentId') commentId?: string,
): Promise<{ type: string | null }> {
// If user is not authenticated, return null
if (!req.user?.id) {
return { type: 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',
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import {
IsString,
IsOptional,
IsUUID,
IsBoolean,
IsInt,
Min,
Max,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
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()
@MaxLength(5000)
content: string;
@IsUUID()
@IsOptional()
articleId?: string;
@IsUUID()
@IsOptional()
liveBlogId?: string;
@IsUUID()
@IsOptional()
parentId?: string;
}
export class UpdateCommentDto {
@IsString()
@MaxLength(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()
@Type(() => Number)
page?: number = 1;
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
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,
};
}
}
+256 -2
View File
@@ -36,6 +36,37 @@ export enum LiveBlogStatus {
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')
export class Author {
@PrimaryGeneratedColumn('uuid')
@@ -94,6 +125,36 @@ export class 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')
export class Article {
@PrimaryGeneratedColumn('uuid')
@@ -114,6 +175,30 @@ export class Article {
@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({
type: 'text',
default: '[]',
@@ -130,7 +215,7 @@ export class Article {
@Column({ default: 0 })
views: number;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@@ -139,6 +224,39 @@ export class Article {
@Column({ nullable: true })
categoryId: string;
@Column({ type: 'text', nullable: true })
ogTitle: string;
@Column({ type: 'text', nullable: true })
ogDescription: string;
@Column({ type: 'text', nullable: true })
ogImage: string;
@Column({ type: 'text', nullable: true })
twitterTitle: string;
@Column({ type: 'text', nullable: true })
twitterDescription: string;
@Column({ type: 'text', nullable: true })
twitterImage: string;
@Column({ default: 0 })
facebookShares: number;
@Column({ default: 0 })
twitterShares: number;
@Column({ default: 0 })
whatsappShares: number;
@Column({ default: 0 })
telegramShares: number;
@Column({ default: false })
isHero: boolean;
@CreateDateColumn()
createdAt: Date;
@@ -152,6 +270,12 @@ export class Article {
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' })
category: Category;
@OneToMany(() => Comment, (comment) => comment.article)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.article)
reactions: Reaction[];
}
@Entity('live_blogs')
@@ -177,7 +301,7 @@ export class LiveBlog {
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@@ -186,6 +310,33 @@ export class LiveBlog {
@Column({ nullable: true })
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 })
viewCount: number;
@@ -207,6 +358,12 @@ export class LiveBlog {
cascade: true,
})
updates: LiveBlogUpdate[];
@OneToMany(() => Comment, (comment) => comment.liveBlog)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
reactions: Reaction[];
}
@Entity('live_blog_updates')
@@ -243,3 +400,100 @@ export class LiveBlogUpdate {
@JoinColumn({ name: 'liveBlogId' })
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,
Put,
Delete,
Patch,
Body,
Param,
Query,
ValidationPipe,
Res,
Logger,
Headers,
UseGuards,
} from '@nestjs/common';
import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service';
@@ -21,6 +22,11 @@ import {
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} 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')
export class LiveBlogController {
@@ -30,68 +36,76 @@ export class LiveBlogController {
// Live Blog CRUD operations
@Get('featured')
@Public()
getFeatured() {
this.logger.log('GET /featured called');
return this.liveBlogService.findPinned();
}
@Get('active')
@Public()
getActive() {
return this.liveBlogService.findActive();
}
@Get('recent')
@Public()
getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
}
@Post()
create(
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
create(@Body() dto: CreateLiveBlogDto) {
return this.liveBlogService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
) {
@Public()
findAll(@Query() dto: FindLiveBlogsDto) {
return this.liveBlogService.findAll(dto);
}
@Get(':id')
@Public()
findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id);
}
@Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) {
return this.liveBlogService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
return this.liveBlogService.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) {
return this.liveBlogService.remove(id);
}
// Live Blog Updates CRUD operations
@Post(':id/updates')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
createUpdate(
@Param('id') liveBlogId: string,
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
@Body() dto: CreateLiveBlogUpdateDto,
) {
return this.liveBlogService.createUpdate(dto, liveBlogId);
}
@Get(':id/updates')
@Public()
findUpdates(
@Param('id') liveBlogId: string,
@Query('page') page = 1,
@@ -105,15 +119,19 @@ export class LiveBlogController {
}
@Put(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
updateUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
@Body() dto: UpdateLiveBlogUpdateDto,
) {
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
}
@Delete(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
removeUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@@ -121,8 +139,23 @@ export class LiveBlogController {
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
@Get(':id/stream')
@Public()
stream(
@Param('id') liveBlogId: string,
@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 { EventEmitterModule } from '@nestjs/event-emitter';
import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({
imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule),
],
controllers: [LiveBlogController],
providers: [LiveBlogService],
+168 -16
View File
@@ -3,12 +3,15 @@ import {
NotFoundException,
Logger,
OnModuleInit,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Response } from 'express';
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
import { StrapiService } from './strapi.service';
import {
CreateLiveBlogDto,
UpdateLiveBlogDto,
@@ -50,6 +53,8 @@ export class LiveBlogService implements OnModuleInit {
@InjectRepository(LiveBlogUpdate)
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {}
onModuleInit() {
@@ -116,13 +121,9 @@ export class LiveBlogService implements OnModuleInit {
} else {
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) {
queryBuilder.andWhere('category.slug = :category', { category });
@@ -252,6 +253,22 @@ export class LiveBlogService implements OnModuleInit {
blogId: id,
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;
@@ -259,9 +276,74 @@ export class LiveBlogService implements OnModuleInit {
async remove(id: string): Promise<void> {
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);
}
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
async createUpdate(
dto: CreateLiveBlogUpdateDto,
@@ -380,7 +462,7 @@ export class LiveBlogService implements OnModuleInit {
const keepAliveInterval = setInterval(() => {
try {
response.write(`: keep-alive\n\n`);
} catch (error) {
} catch {
// Client disconnected, stop sending keep-alive
clearInterval(keepAliveInterval);
}
@@ -425,21 +507,91 @@ export class LiveBlogService implements OnModuleInit {
strapiId: string,
data: Partial<CreateLiveBlogDto>,
): 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 },
});
if (liveBlog) {
// 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,
...data,
status: data.status || LiveBlogStatus.DRAFT,
});
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(existingLiveBlog);
}
}
// Re-throw other errors
throw error;
}
}
async removeByStrapiId(strapiId: string): Promise<void> {
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (!liveBlog) {
liveBlog = this.liveBlogRepository.create({
strapiId,
...data,
status: data.status || LiveBlogStatus.DRAFT,
});
} else {
Object.assign(liveBlog, data);
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
return;
}
return await this.liveBlogRepository.save(liveBlog);
await this.liveBlogRepository.remove(liveBlog);
this.logger.log(
`Successfully deleted live blog with strapiId: ${strapiId}`,
);
}
// Utility methods
+13 -2
View File
@@ -1,8 +1,14 @@
import { Controller, Post, Body, Logger } from '@nestjs/common';
import { StrapiService } from './strapi.service';
import { Public } from './auth/public.decorator';
interface WebhookBody {
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish';
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish';
model: string;
entry: {
documentId: string;
@@ -16,6 +22,7 @@ export class StrapiController {
constructor(private readonly strapiService: StrapiService) {}
@Post('article')
@Public()
async handleArticleWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body;
@@ -33,6 +40,7 @@ export class StrapiController {
}
@Post('live-blog')
@Public()
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body;
@@ -50,6 +58,7 @@ export class StrapiController {
}
@Post()
@Public()
async handleGenericWebhook(@Body() body: unknown) {
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
@@ -91,6 +100,7 @@ export class StrapiController {
'entry.update',
'entry.delete',
'entry.publish',
'entry.unpublish',
];
if (!validEvents.includes(event)) {
this.logger.warn(`Invalid event type: ${event}`);
@@ -102,7 +112,8 @@ export class StrapiController {
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish',
| 'entry.publish'
| 'entry.unpublish',
{ documentId, model },
);
return { message: 'Webhook processed successfully' };
+9 -2
View File
@@ -1,12 +1,19 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module';
import { Category } from './entities';
@Module({
imports: [HttpModule, ArticlesModule, LiveBlogModule],
imports: [
HttpModule,
TypeOrmModule.forFeature([Category]),
forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule),
],
controllers: [StrapiController],
providers: [StrapiService],
exports: [StrapiService],
+528 -35
View File
@@ -1,11 +1,35 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
import { ArticleStatus, LiveBlogStatus } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
Category,
} 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 {
id: number;
@@ -17,6 +41,14 @@ interface StrapiArticle {
publishedAt: string | null;
createdAt: string;
updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
category?: string;
}
interface StrapiLiveBlog {
@@ -29,6 +61,14 @@ interface StrapiLiveBlog {
publishedAt: string | null;
createdAt: string;
updatedAt: string;
img?: StrapiImage;
media?: StrapiImage[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
category?: string;
}
interface StrapiResponse<T> {
@@ -52,8 +92,12 @@ export class StrapiService {
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
@Inject(forwardRef(() => ArticlesService))
private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService,
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) {
this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -62,13 +106,105 @@ export class StrapiService {
}
private getHeaders() {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
return {
Authorization: `Bearer ${this.strapiApiToken}`,
};
if (this.strapiApiToken) {
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
}
private async findOrCreateCategory(
categorySlug: string,
): Promise<Category | null> {
if (!categorySlug) {
return null;
}
return headers;
// Map CMS category slugs to Macedonian display names
const categoryMap: Record<string, { name: string; description: string }> = {
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
science: { name: 'Наука', description: 'Научни откритија и технологија' },
};
const categoryInfo = categoryMap[categorySlug];
if (!categoryInfo) {
this.logger.warn(`Unknown category slug: ${categorySlug}`);
return null;
}
// Try to find existing category by slug
let category = await this.categoryRepository.findOne({
where: { slug: categorySlug },
});
if (!category) {
// Create new category
category = this.categoryRepository.create({
name: categoryInfo.name,
slug: categorySlug,
description: categoryInfo.description,
order: 0,
});
category = await this.categoryRepository.save(category);
this.logger.log(
`Created new category: ${categorySlug} (${categoryInfo.name})`,
);
}
return category;
}
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> {
@@ -77,7 +213,7 @@ export class StrapiService {
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles`,
`${this.strapiUrl}/api/articles?populate=*`,
{
headers: this.getHeaders(),
},
@@ -88,6 +224,19 @@ export class StrapiService {
let syncedCount = 0;
for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
@@ -97,6 +246,15 @@ export class StrapiService {
? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT,
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 || '',
categoryId,
};
await this.articlesService.syncFromStrapi(
@@ -115,13 +273,23 @@ export class StrapiService {
}
}
async syncSingleArticle(strapiId: string): Promise<void> {
async syncSingleArticle(
strapiId: string,
event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> {
try {
this.logger.log(`Syncing single article from Strapi: ${strapiId}`);
this.logger.log(
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>(
`${this.strapiUrl}/api/articles/${strapiId}`,
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
{
headers: this.getHeaders(),
},
@@ -129,28 +297,89 @@ export class StrapiService {
);
const strapiArticle = response.data.data;
// Determine status based on publishedAt and event type
let status: ArticleStatus;
if (event === 'entry.unpublish') {
// If it's an unpublish event, always mark as draft
status = ArticleStatus.DRAFT;
} else if (strapiArticle.publishedAt) {
// If publishedAt exists, it's published
status = ArticleStatus.PUBLISHED;
} else {
// Otherwise it's a draft
status = ArticleStatus.DRAFT;
}
const imageUrl = this.extractImageUrl(strapiArticle);
// Find or create category
let categoryId: string | undefined;
if (strapiArticle.category) {
const category = await this.findOrCreateCategory(
strapiArticle.category,
);
if (category) {
categoryId = category.id;
}
}
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
content: strapiArticle.content,
slug: strapiArticle.slug,
status: strapiArticle.publishedAt
? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT,
status,
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 || '',
categoryId,
};
await this.articlesService.syncFromStrapi(
strapiArticle.documentId,
articleData,
);
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
} catch (error) {
this.logger.error(
`Failed to sync article ${strapiId} from Strapi`,
error,
this.logger.log(
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
);
throw error;
} catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft
const axiosError = error as { response?: { status: number } };
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 {
const articleData: Partial<CreateArticleDto> = {
status: ArticleStatus.DRAFT,
};
await this.articlesService.syncFromStrapi(strapiId, articleData);
this.logger.log(
`Marked article ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) {
this.logger.error(
`Failed to mark article ${strapiId} as draft:`,
syncError,
);
throw syncError;
}
} else {
this.logger.error(
`Failed to sync article ${strapiId} from Strapi`,
error,
);
throw error;
}
}
}
@@ -160,7 +389,7 @@ export class StrapiService {
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs`,
`${this.strapiUrl}/api/live-blogs?populate=*`,
{
headers: this.getHeaders(),
},
@@ -171,11 +400,33 @@ export class StrapiService {
let syncedCount = 0;
for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title,
description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug,
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 || '',
categoryId,
};
await this.liveBlogService.syncFromStrapi(
@@ -194,13 +445,23 @@ export class StrapiService {
}
}
async syncSingleLiveBlog(strapiId: string): Promise<void> {
async syncSingleLiveBlog(
strapiId: string,
event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> {
try {
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}`);
this.logger.log(
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{
headers: this.getHeaders(),
},
@@ -208,24 +469,83 @@ export class StrapiService {
);
const strapiLiveBlog = response.data.data;
// For live blogs, we use the status from Strapi directly
// but we might want to handle unpublish events differently
let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status);
// If it's an unpublish event, set to draft
if (event === 'entry.unpublish') {
status = LiveBlogStatus.DRAFT;
}
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
// Find or create category
let categoryId: string | undefined;
if (strapiLiveBlog.category) {
const category = await this.findOrCreateCategory(
strapiLiveBlog.category,
);
if (category) {
categoryId = category.id;
}
}
const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title,
description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.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 || '',
categoryId,
};
await this.liveBlogService.syncFromStrapi(
strapiLiveBlog.documentId,
liveBlogData,
);
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title}`);
} catch (error) {
this.logger.error(
`Failed to sync live blog ${strapiId} from Strapi`,
error,
this.logger.log(
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
);
throw error;
} catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft
const axiosError = error as { response?: { status: number } };
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 {
const liveBlogData: Partial<CreateLiveBlogDto> = {
status: LiveBlogStatus.DRAFT,
};
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
this.logger.log(
`Marked live blog ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) {
this.logger.error(
`Failed to mark live blog ${strapiId} as draft:`,
syncError,
);
throw syncError;
}
} else {
this.logger.error(
`Failed to sync live blog ${strapiId} from Strapi`,
error,
);
throw error;
}
}
}
@@ -245,7 +565,12 @@ export class StrapiService {
}
async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish',
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string },
): Promise<void> {
this.logger.log(
@@ -253,17 +578,185 @@ export class StrapiService {
);
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;
}
// Route to appropriate sync method based on model
if (data.model === 'article') {
await this.syncSingleArticle(data.documentId);
await this.syncSingleArticle(data.documentId, event);
} else if (data.model === 'live-blog') {
await this.syncSingleLiveBlog(data.documentId);
await this.syncSingleLiveBlog(data.documentId, event);
} else {
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,36 @@
"videos",
"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": ""
},
"category": {
"type": "enumeration",
"enum": ["sport", "art", "science"],
"default": "sport",
"required": true
}
}
}
+12
View File
@@ -446,6 +446,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
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'>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
@@ -462,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
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<''>;
};
}
+9 -18
View File
@@ -8,6 +8,7 @@ services:
environment:
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: placebo_password
POSTGRES_DB: placebo_backend_db
volumes:
- postgres_data_dev:/var/lib/postgresql/data
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
@@ -22,21 +23,11 @@ services:
context: ./backend
dockerfile: Dockerfile.dev
container_name: placebo-backend-dev
env_file:
- ./backend/.env.docker # Use the .env.docker file for Docker configuration
environment:
# Only override if needed, most config is in .env.docker file
NODE_ENV: development
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password
DATABASE_NAME: placebo_backend_db
DATABASE_SYNCHRONIZE: "true"
DATABASE_LOGGING: "true"
JWT_SECRET: dev-jwt-secret
JWT_EXPIRATION: 3600
CORS_ORIGIN: http://localhost:5173
PORT: 3000
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: 578d628f62df967ff95f95bedb205b5d10bbf792340519c8c467d6473208e16b3918151a97b49fa2285a53df0ec8e340a9ca555b01a654bd22152847840e6a368ee626a6f1338ce2f23790c171013b263ec80fbaf116e2b459d3663b234d08855fd0eb631991ed15bb94f7dbb0b80f190352965c72c7fd327c73629ceff38fbb
ports:
- "3000:3000"
depends_on:
@@ -45,8 +36,8 @@ services:
- ./backend/src:/app/src
- ./backend/package.json:/app/package.json
- ./backend/package-lock.json:/app/package-lock.json
- ./backend/.env:/app/.env
command: npm run start:dev
- ./backend/.env.docker:/app/.env # Mount .env.docker file as .env in container
command: npm run dev:docker
networks:
- placebo-network-dev
@@ -56,10 +47,10 @@ services:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: placebo-frontend-dev
env_file:
- ./frontend/.env # Use the .env file for configuration
environment:
NODE_ENV: development
VITE_API_URL: http://localhost:3000/api/v1
VITE_CMS_URL: http://localhost:1337
ports:
- "5173:5173"
depends_on:
@@ -75,7 +66,7 @@ services:
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
- ./frontend/tailwind.config.js:/app/tailwind.config.js
- ./frontend/.env:/app/.env
command: npm run dev
command: npm run dev:docker
networks:
- placebo-network-dev
+13 -15
View File
@@ -6,7 +6,7 @@ services:
image: postgres:16-alpine
container_name: placebo-postgres
environment:
POSTGRES_DB: placebo_db
POSTGRES_DB: placebo_backend_db
POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: placebo_password
volumes:
@@ -15,7 +15,7 @@ services:
ports:
- "5432:5432"
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
timeout: 5s
retries: 5
@@ -29,19 +29,17 @@ services:
dockerfile: Dockerfile
container_name: placebo-backend
environment:
NODE_ENV: production
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password
DATABASE_NAME: placebo_db
DATABASE_SYNCHRONIZE: "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
PORT: 3000
NODE_ENV: production
DATABASE_TYPE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password
DATABASE_NAME: placebo_backend_db
DATABASE_SYNCHRONIZE: "false"
DATABASE_LOGGING: "false"
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
PORT: 3000
ports:
- "3000:3000"
depends_on:
+12
View File
@@ -1 +1,13 @@
# Frontend Environment Configuration
# Copy to .env and adjust for your setup
# ===== DOCKER MODE (frontend in container) =====
# Use when frontend runs in Docker container
# VITE_API_URL=http://backend:3000/api/v1
# ===== LOCAL MODE (frontend runs locally) =====
# Use when frontend runs locally with npm run dev
VITE_API_URL=http://localhost:3000/api/v1
# ===== COMMON =====
VITE_CMS_URL=http://localhost:1337
+1471 -22
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -12,7 +12,10 @@
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
"test:coverage": "vitest --coverage",
"dev:docker": "vite",
"dev:local": "cp -f .env.local .env && vite",
"dev:reset-env": "cp -f .env.docker .env"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
@@ -25,7 +28,9 @@
"@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.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": {
"@eslint/js": "^9.39.1",
+2 -2
View File
@@ -24,7 +24,7 @@ export function ArticleTicker() {
{articles.map((article, index) => (
<Link
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"
>
{article.title || 'No title'}
@@ -34,7 +34,7 @@ export function ArticleTicker() {
{articles.map((article, index) => (
<Link
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"
>
{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,154 @@
import React, { useState } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
import { 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';
import type { Comment } from '../../../lib/api';
interface CommentItemProps {
comment: Comment;
articleId?: string;
liveBlogId?: string;
depth?: number;
}
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
const { isAuthenticated } = useAuth();
const [showReplyForm, setShowReplyForm] = useState(false);
const [replyContent, setReplyContent] = useState('');
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const createCommentMutation = useCreateComment();
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim() || !isAuthenticated) return;
setIsSubmittingReply(true);
try {
await createCommentMutation.mutateAsync({
content: replyContent,
articleId,
liveBlogId,
parentCommentId: comment.id,
});
setReplyContent('');
setShowReplyForm(false);
} catch (error) {
console.error('Failed to post reply:', error);
} finally {
setIsSubmittingReply(false);
}
};
// Maximum depth to prevent infinite nesting (optional)
const maxDepth = 5;
const canReply = depth < maxDepth;
return (
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
<Card>
<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 mb-4">{comment.content}</p>
{/* Reply button and form */}
{isAuthenticated && canReply && (
<div className="mt-4">
{!showReplyForm ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplyForm(true)}
className="text-sm"
>
Одговори
</Button>
) : (
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
<form onSubmit={handleSubmitReply}>
<Textarea
placeholder="Вашиот одговор..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
className="min-h-[80px] mb-3"
disabled={isSubmittingReply}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowReplyForm(false);
setReplyContent('');
}}
disabled={isSubmittingReply}
>
Откажи
</Button>
<Button
type="submit"
size="sm"
disabled={!replyContent.trim() || isSubmittingReply}
>
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
</Button>
</div>
</form>
</div>
)}
</div>
)}
{/* Reactions (if implemented) */}
{comment.reactions && (
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Render replies recursively */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-4">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
articleId={articleId}
liveBlogId={liveBlogId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,115 @@
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 { CommentItem } from './CommentItem';
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) => (
<CommentItem
key={comment.id}
comment={comment}
articleId={articleId}
liveBlogId={liveBlogId}
/>
))}
</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>
);
}
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Copy, Check } from 'lucide-react';
import { copyToClipboard } from '@/lib/social-utils';
interface CopyLinkButtonProps {
url: string;
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
className?: string;
showLabel?: boolean;
onCopy?: (success: boolean) => void;
}
export function CopyLinkButton({
url,
size = 'default',
variant = 'outline',
className = '',
showLabel = false,
onCopy,
}: CopyLinkButtonProps) {
const [isCopied, setIsCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleCopy = async () => {
if (isLoading) return;
setIsLoading(true);
try {
const success = await copyToClipboard(url);
setIsCopied(success);
if (onCopy) {
onCopy(success);
}
// Reset copied state after 2 seconds
if (success) {
setTimeout(() => setIsCopied(false), 2000);
}
} catch (error) {
console.error('Failed to copy:', error);
if (onCopy) {
onCopy(false);
}
} finally {
setIsLoading(false);
}
};
const label = isCopied ? 'Copied!' : 'Copy Link';
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleCopy}
disabled={isLoading}
className={`relative ${className}`}
aria-label={label}
title={label}
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : isCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
}
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { type SharePlatform, getPlatformLabel } from '@/lib/social-utils';
import {
Facebook,
Twitter,
MessageCircle,
Send,
Mail,
Link,
Share2
} from 'lucide-react';
interface ShareButtonProps {
platform: SharePlatform;
onClick: () => void;
disabled?: boolean;
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
className?: string;
showLabel?: boolean;
}
export function ShareButton({
platform,
onClick,
disabled = false,
size = 'default',
variant = 'outline',
className = '',
showLabel = false,
}: ShareButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
if (disabled || isLoading) return;
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
const label = getPlatformLabel(platform);
// Get the appropriate icon component
const getIconComponent = () => {
switch (platform) {
case 'facebook':
return Facebook;
case 'twitter':
return Twitter;
case 'whatsapp':
return MessageCircle;
case 'telegram':
return Send;
case 'email':
return Mail;
case 'link':
return Link;
default:
return Share2;
}
};
const IconComponent = getIconComponent();
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleClick}
disabled={disabled || isLoading}
className={`relative ${className}`}
aria-label={label}
title={label}
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : (
<IconComponent className="h-4 w-4" />
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
}
@@ -0,0 +1,188 @@
import { useState } from 'react';
import { ShareButton } from './ShareButton';
import { CopyLinkButton } from './CopyLinkButton';
import { type SharePlatform, type ShareData, getShareUrl } from '@/lib/social-utils';
import { trackShare } from '@/lib/analytics';
export type SocialShareVariant = 'default' | 'compact' | 'footer' | 'floating';
interface SocialShareButtonsProps extends ShareData {
articleId: string;
variant?: SocialShareVariant;
className?: string;
onShare?: (platform: SharePlatform) => void;
}
const PLATFORMS: SharePlatform[] = ['facebook', 'twitter', 'whatsapp', 'telegram', 'email', 'link'];
export function SocialShareButtons({
articleId,
title,
url,
excerpt,
image,
tags,
variant = 'default',
className = '',
onShare,
}: SocialShareButtonsProps) {
const [isTracking, setIsTracking] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const shareData: ShareData = {
title,
url,
excerpt,
image,
tags,
};
const handleShare = async (platform: SharePlatform) => {
try {
// Track the share event
setIsTracking(true);
await trackShare({
articleId,
platform,
userAgent: navigator.userAgent,
// Note: We don't send IP address from frontend for privacy reasons
// Backend should extract it from the request if needed
});
// Call the onShare callback if provided
if (onShare) {
onShare(platform);
}
// Open share URL in new window for social platforms
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, shareData);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
console.error('Failed to track share:', error);
// Still open the share URL even if tracking fails
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, shareData);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} finally {
setIsTracking(false);
}
};
const handleCopyLink = async (success: boolean) => {
if (success) {
await handleShare('link');
}
};
// Determine layout based on variant
const getLayoutClasses = () => {
switch (variant) {
case 'compact':
return 'flex items-center space-x-1';
case 'footer':
return 'flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-4';
case 'floating':
return 'fixed right-4 bottom-4 flex flex-col space-y-2 z-50';
default:
return 'flex flex-wrap items-center gap-2';
}
};
const getButtonSize = () => {
switch (variant) {
case 'compact':
return 'sm' as const;
case 'footer':
return 'default' as const;
case 'floating':
return 'default' as const;
default:
return 'default' as const;
}
};
const getButtonVariant = () => {
switch (variant) {
case 'compact':
return 'ghost' as const;
case 'footer':
return 'outline' as const;
case 'floating':
return 'default' as const;
default:
return 'outline' as const;
}
};
const showLabels = variant === 'footer';
// For compact variant, only show a single share button that expands on hover
if (variant === 'compact') {
return (
<div
className={`relative ${className}`}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<div className="flex items-center space-x-1">
<ShareButton
platform="link"
onClick={() => handleShare('link')}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
/>
{isExpanded && (
<div className="flex items-center space-x-1 animate-in slide-in-from-right-2">
{PLATFORMS.filter(p => p !== 'link').map((platform) => (
<ShareButton
key={platform}
platform={platform}
onClick={() => handleShare(platform)}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className={`${getLayoutClasses()} ${className}`}>
{PLATFORMS.map((platform) => {
if (platform === 'link') {
return (
<CopyLinkButton
key={platform}
url={url}
size={getButtonSize()}
variant={getButtonVariant()}
showLabel={showLabels}
onCopy={handleCopyLink}
/>
);
}
return (
<ShareButton
key={platform}
platform={platform}
onClick={() => handleShare(platform)}
size={getButtonSize()}
variant={getButtonVariant()}
disabled={isTracking}
showLabel={showLabels}
/>
);
})}
</div>
);
}
@@ -0,0 +1,4 @@
export { SocialShareButtons } from './SocialShareButtons';
export { ShareButton } from './ShareButton';
export { CopyLinkButton } from './CopyLinkButton';
export type { SocialShareVariant } from './SocialShareButtons';
@@ -0,0 +1,150 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchHeroArticle } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Calendar, User, ArrowRight } from 'lucide-react';
export function HeroArticle() {
const { data: article, isLoading, error } = useQuery({
queryKey: ['hero-article'],
queryFn: fetchHeroArticle,
});
if (isLoading) {
return (
<div className="rounded-xl border bg-card p-8 animate-pulse">
<div className="h-8 bg-muted rounded w-3/4 mb-4"></div>
<div className="h-4 bg-muted rounded w-1/2 mb-6"></div>
<div className="h-64 bg-muted rounded mb-6"></div>
<div className="h-4 bg-muted rounded w-full mb-2"></div>
<div className="h-4 bg-muted rounded w-5/6 mb-6"></div>
<div className="h-10 bg-muted rounded w-32"></div>
</div>
);
}
if (error) {
return (
<div className="rounded-xl border bg-card p-8 text-center">
<div className="text-red-500 mb-4">Error loading hero article</div>
<Button variant="outline" onClick={() => window.location.reload()}>
Try Again
</Button>
</div>
);
}
if (!article) {
return (
<div className="rounded-xl border bg-card p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
</div>
<h2 className="text-2xl font-bold mb-4">No Hero Article Set</h2>
<p className="text-muted-foreground mb-6">
Mark an article as "Hero" in the admin panel to feature it here.
</p>
<div className="text-sm text-muted-foreground">
This space will showcase your most important story.
</div>
</div>
);
}
return (
<div className="rounded-xl border bg-card overflow-hidden hover:shadow-lg transition-shadow duration-300">
{/* Featured Image */}
{article.featuredImage && (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="absolute bottom-4 left-6 right-6">
<span className="inline-block px-3 py-1 bg-primary text-primary-foreground text-xs font-semibold rounded-full mb-2">
Featured Story
</span>
</div>
</div>
)}
{/* Content */}
<div className="p-6 md:p-8">
<h2 className="text-2xl md:text-3xl font-bold mb-4 line-clamp-2">
{article.title}
</h2>
{/* Meta Information */}
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground mb-6">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
{article.author && (
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
<span>{article.author.name}</span>
</div>
)}
<div className="flex items-center gap-1">
<span>{article.views} views</span>
</div>
</div>
{/* Excerpt */}
{article.excerpt && (
<p className="text-muted-foreground mb-6 line-clamp-3">
{article.excerpt}
</p>
)}
{/* Tags */}
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{article.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-xs rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* Read More Button */}
<div className="flex items-center justify-between">
<Link to={`/articles/${article.id}`}>
<Button className="gap-2">
Read Full Story
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
{/* Social shares count */}
<div className="text-sm text-muted-foreground">
<span className="font-medium">
{article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares}
</span> shares
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,147 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
export function LatestArticlesGrid() {
const { data, isLoading, error } = useQuery({
queryKey: ['latest-articles'],
queryFn: () => api.fetchLatestArticles(12),
})
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="p-6 rounded-xl border bg-card animate-pulse">
<div className="h-48 bg-muted rounded-lg mb-4"></div>
<div className="h-4 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded mb-2 w-3/4"></div>
<div className="h-3 bg-muted rounded mb-4 w-1/2"></div>
<div className="flex justify-between">
<div className="h-3 bg-muted rounded w-1/4"></div>
<div className="h-3 bg-muted rounded w-1/4"></div>
</div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="rounded-xl border border-dashed border-red-200 bg-red-50 p-8 text-center">
<div className="text-red-500 mb-2">Грешка при вчитување на статии</div>
<p className="text-sm text-red-600">Обидете се повторно</p>
</div>
)
}
const articles = data?.data || []
if (articles.length === 0) {
return (
<div className="rounded-xl border border-dashed border-muted p-8 text-center">
<div className="text-muted-foreground mb-2">Нема објавени статии</div>
<p className="text-sm text-muted-foreground">Проверете подоцна</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Најнови статии</h2>
<Link
to="/archive"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
Види сите
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{articles.map((article) => (
<div
key={article.id}
className="group p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
{article.featuredImage ? (
<div className="relative h-48 overflow-hidden rounded-lg mb-4">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
) : (
<div className="h-48 bg-gradient-to-br from-primary/10 to-primary/5 rounded-lg mb-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
<h3 className="text-lg font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h3>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} прегледи</span>
</div>
{article.category && (
<Link
to={`/${article.category.slug}`}
className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
{article.category.name}
</Link>
)}
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,193 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchPinnedLiveBlogs } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar, Eye, MessageSquare, Pin } from 'lucide-react';
export function PinnedLiveBlogsSidebar() {
const { data: liveBlogs, isLoading, error } = useQuery({
queryKey: ['pinned-live-blogs'],
queryFn: fetchPinnedLiveBlogs,
});
const getStatusBadge = (status: string) => {
switch (status) {
case 'live':
return (
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
LIVE
</Badge>
);
case 'ended':
return (
<Badge variant="outline" className="border-gray-400 text-gray-400">
ENDED
</Badge>
);
case 'archived':
return (
<Badge variant="outline" className="border-gray-500 text-gray-500">
ARCHIVED
</Badge>
);
default:
return (
<Badge variant="outline">DRAFT</Badge>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
});
};
if (isLoading) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-3 bg-muted rounded w-1/2"></div>
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<div className="text-center py-4">
<div className="text-red-500 text-sm mb-2">Error loading live blogs</div>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}
if (!liveBlogs || liveBlogs.length === 0) {
return (
<div className="rounded-xl border bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<div className="text-center py-6">
<div className="text-muted-foreground mb-2">No pinned live blogs</div>
<p className="text-sm text-muted-foreground">
Pin live blogs from the admin panel to feature them here.
</p>
</div>
</div>
);
}
return (
<div className="rounded-xl border bg-card p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Pin className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Pinned Live Blogs</h3>
</div>
<Badge variant="outline" className="text-xs">
{liveBlogs.length} pinned
</Badge>
</div>
{/* Live Blogs List */}
<div className="space-y-4">
{liveBlogs.map((liveBlog) => (
<Link
key={liveBlog.id}
to={`/live-blogs/${liveBlog.id}`}
className="block group"
>
<div className="p-4 rounded-lg border hover:border-primary/50 hover:bg-accent/50 transition-colors group-hover:shadow-sm">
{/* Title and Status */}
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium line-clamp-2 group-hover:text-primary transition-colors">
{liveBlog.title}
</h4>
{getStatusBadge(liveBlog.status)}
</div>
{/* Description */}
{liveBlog.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{liveBlog.description}
</p>
)}
{/* Meta Information */}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(liveBlog.createdAt)}</span>
</div>
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{liveBlog.viewCount} views</span>
</div>
{liveBlog.updates && liveBlog.updates.length > 0 && (
<div className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
<span>{liveBlog.updates.length} updates</span>
</div>
)}
{liveBlog.author && (
<div className="text-xs text-muted-foreground">
by {liveBlog.author.name}
</div>
)}
</div>
{/* Featured Image (small) */}
{liveBlog.featuredImage && (
<div className="mt-3">
<div className="relative h-20 rounded-md overflow-hidden">
<img
src={liveBlog.featuredImage}
alt={liveBlog.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
</div>
</div>
)}
</div>
</Link>
))}
</div>
{/* View All Link */}
<div className="mt-6 pt-6 border-t">
<Link to="/live-blogs" className="block">
<Button variant="outline" className="w-full justify-center">
View All Live Blogs
</Button>
</Link>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../ui/button';
import { ThemeToggle } from './ThemeToggle';
import { Menu, X } from 'lucide-react';
export function Header() {
const { user, logout, isAuthenticated, hasRole } = useAuth();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
const closeMobileMenu = () => {
setIsMobileMenuOpen(false);
};
const navLinks = [
{ to: '/', label: 'Почетна' },
{ to: '/sport', label: 'Спорт' },
{ to: '/art', label: 'Уметност' },
{ to: '/science', label: 'Наука' },
{ to: '/archive', label: 'Архива' },
{ to: '/live-blogs', label: 'Live' },
];
const adminLinks = [
{ to: '/admin', label: 'Admin' },
{ to: '/admin/live-blogs/create', label: '+ New Live Blog' },
];
return (
<header className="border-b sticky top-0 z-50 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl md:text-3xl font-bold">
<Link to="/" className="hover:underline">Placebo.mk</Link>
</h1>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-4">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm font-medium hover:underline transition-colors"
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm font-medium hover:underline text-primary transition-colors"
>
{link.label}
</Link>
))}
</>
)}
<div className="flex items-center gap-2 ml-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 transition-colors">
Login / Register
</Link>
)}
<ThemeToggle />
</nav>
{/* Mobile Menu Button */}
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<Button
variant="ghost"
size="icon"
onClick={toggleMobileMenu}
className="h-9 w-9"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t pt-4 animate-in slide-in-from-top-2">
<div className="flex flex-col gap-3">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm font-medium hover:underline py-2 transition-colors"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<div className="border-t pt-3 mt-2">
<p className="text-xs text-muted-foreground mb-2">Admin</p>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-sm font-medium hover:underline text-primary py-2 block transition-colors"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
</div>
)}
<div className="border-t pt-3 mt-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Logged in as: {user?.username}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
logout();
closeMobileMenu();
}}
className="text-xs"
>
Logout
</Button>
</div>
</div>
</>
) : (
<Link
to="/auth"
className="text-sm font-medium hover:underline text-primary py-2 transition-colors"
onClick={closeMobileMenu}
>
Login / Register
</Link>
)}
</div>
</div>
)}
</div>
</header>
);
}
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
import { getInitialTheme, toggleTheme, watchSystemTheme } from '@/lib/theme';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>(getInitialTheme);
useEffect(() => {
// Watch for system theme changes
const cleanup = watchSystemTheme((newTheme) => {
setTheme(newTheme);
});
return cleanup;
}, []);
const handleToggle = () => {
const newTheme = toggleTheme();
setTheme(newTheme);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className="h-9 w-9"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
</Button>
);
}
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles } from '@/queries/articles';
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -8,11 +10,112 @@ import { format } from 'date-fns';
import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() {
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 });
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 });
// State for confirmation dialog and filters
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 updateArticleMutation = useUpdateArticle();
const liveBlogs = liveBlogsData?.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 handleSetHero = async (articleId: string, isHero: boolean) => {
setIsProcessing(true);
try {
await updateArticleMutation.mutateAsync({
id: articleId,
dto: { isHero }
});
} catch (error) {
console.error('Failed to update hero status:', error);
} finally {
setIsProcessing(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
@@ -49,14 +152,20 @@ export function AdminDashboardComponent() {
Управување со сите написи и live блогови
</p>
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
<Button asChild>
<Link to="/">Назад кон сајтот</Link>
</Button>
</div>
<div className="flex gap-2">
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button>
<Button asChild>
<Link to="/">Назад кон сајтот</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
@@ -64,10 +173,10 @@ export function AdminDashboardComponent() {
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Live блогови</span>
<Badge variant="outline" className="ml-2">
{liveBlogs.length || 0}
</Badge>
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
<Badge variant="outline" className="ml-2">
{filteredLiveBlogs.length || 0}
</Badge>
</CardTitle>
<CardDescription>
Сите live блогови со статус и датум на креирање
@@ -79,16 +188,20 @@ export function AdminDashboardComponent() {
<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>
) : liveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">Нема live блогови</p>
<Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{liveBlogs.map((blog) => (
) : filteredLiveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filteredLiveBlogs.map((blog) => (
<div
key={blog.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
@@ -96,8 +209,9 @@ export function AdminDashboardComponent() {
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link
to={`/admin/live-blogs/${blog.slug}` as any}
<Link
to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }}
className="font-medium hover:text-primary hover:underline"
>
{blog.title}
@@ -123,13 +237,40 @@ export function AdminDashboardComponent() {
</div>
<div className="flex gap-2 ml-4">
<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 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>
</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>
@@ -143,10 +284,10 @@ export function AdminDashboardComponent() {
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Написи</span>
<Badge variant="outline" className="ml-2">
{articles.length || 0}
</Badge>
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
<Badge variant="outline" className="ml-2">
{filteredArticles.length || 0}
</Badge>
</CardTitle>
<CardDescription>
Сите написи со статус и датум на креирање
@@ -158,33 +299,43 @@ export function AdminDashboardComponent() {
<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>
) : articles.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">Нема написи</p>
<Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{articles.map((article) => (
) : filteredArticles.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filteredArticles.map((article) => (
<div
key={article.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link
to={`/articles/${article.id}` as any}
className="font-medium hover:text-primary hover:underline"
>
{article.title}
</Link>
<Badge variant="outline" className={getStatusColor(article.status)}>
{getStatusText(article.status)}
</Badge>
</div>
<div className="flex items-center gap-2 mb-1">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline"
>
{article.title}
</Link>
<Badge variant="outline" className={getStatusColor(article.status)}>
{getStatusText(article.status)}
</Badge>
{article.isHero && (
<Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
★ Hero
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Слаг: {article.slug}</span>
<span>•</span>
@@ -193,6 +344,11 @@ export function AdminDashboardComponent() {
</span>
<span>•</span>
<span>Прегледи: {article.views}</span>
<span>•</span>
<span>
Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) +
(article.whatsappShares || 0) + (article.telegramShares || 0)}
</span>
</div>
{article.excerpt && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
@@ -200,16 +356,51 @@ export function AdminDashboardComponent() {
</p>
)}
</div>
<div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline">
<Link to={`/articles/${article.id}` as any}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to={`/articles/${article.id}` as any} target="_blank">
Преглед
</Link>
</Button>
</div>
<div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline">
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link to="/articles/$id" params={{ id: article.id }} target="_blank">
Преглед
</Link>
</Button>
<Button
size="sm"
variant={article.isHero ? "default" : "outline"}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set as Hero'}
</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>
))}
@@ -219,42 +410,207 @@ export function AdminDashboardComponent() {
</Card>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{liveBlogs.filter(b => b.status === 'live').length || 0}
</div>
<p className="text-sm text-muted-foreground">Активни live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.filter(a => a.status === 'published').length || 0}
</div>
<p className="text-sm text-muted-foreground">Објавени написи</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{liveBlogs.filter(b => b.isPinned).length || 0}
</div>
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
</CardContent>
</Card>
</div>
</div>
);
}
{/* Quick Stats - Only show when not viewing archived items */}
{!showArchived && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{liveBlogs.filter(b => b.status === 'live').length || 0}
</div>
<p className="text-sm text-muted-foreground">Активни live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.filter(a => a.status === 'published').length || 0}
</div>
<p className="text-sm text-muted-foreground">Објавени написи</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{liveBlogs.filter(b => b.isPinned).length || 0}
</div>
<p className="text-sm text-muted-foreground">Закачени live блогови</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
</CardContent>
</Card>
</div>
)}
{/* Social Media Analytics - Only show when not viewing archived items */}
{!showArchived && (
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle>Social Media Analytics</CardTitle>
<CardDescription>Share statistics for articles</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Total Shares Summary */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Facebook Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Twitter Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">WhatsApp Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Telegram Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.reduce((sum, a) =>
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-sm text-muted-foreground">Total Shares</p>
</CardContent>
</Card>
</div>
{/* Top Shared Articles */}
<div>
<h3 className="text-lg font-semibold mb-4">Top Shared Articles</h3>
<div className="space-y-3">
{articles
.filter(a => a.status === 'published')
.sort((a, b) => {
const aShares = (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0);
const bShares = (b.facebookShares || 0) + (b.twitterShares || 0) +
(b.whatsappShares || 0) + (b.telegramShares || 0);
return bShares - aShares;
})
.slice(0, 5)
.map((article) => {
const totalShares = (article.facebookShares || 0) +
(article.twitterShares || 0) +
(article.whatsappShares || 0) +
(article.telegramShares || 0);
const shareRate = article.views > 0
? ((totalShares / article.views) * 100).toFixed(2)
: '0.00';
return (
<div
key={article.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline"
>
{article.title}
</Link>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Views: {article.views}</span>
<span>•</span>
<span>Shares: {totalShares}</span>
<span>•</span>
<span>Share Rate: {shareRate}%</span>
</div>
<div className="flex items-center gap-4 mt-2 text-xs">
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
Facebook: {article.facebookShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-sky-500 rounded-full"></span>
Twitter: {article.twitterShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
WhatsApp: {article.whatsappShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-cyan-500 rounded-full"></span>
Telegram: {article.telegramShares || 0}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</CardContent>
</Card>
</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>
);
}
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArchiveComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} views</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ArtComponent() {
return (
<CategoryPage
categorySlug="art"
categoryName="Уметност"
categoryDescription="Уметност, култура и забава"
/>
)
}
@@ -1,6 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
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'
import { SocialShareButtons } from '@/components/features/social-share'
export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({
@@ -63,21 +70,134 @@ export function ArticleDetailComponent({ id }: { id: string }) {
<span>By {data.author.name}</span>
</>
)}
</div>
</div>
{data.featuredImage && (
<img
src={data.featuredImage}
alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8"
/>
{/* Social Sharing */}
<div className="mb-8">
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
/>
</div>
{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
src={data.featuredImage}
alt={data.title}
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">
<p className="text-lg leading-relaxed mb-6">{data.content}</p>
</div>
<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>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
{/* Social Sharing Footer */}
<div className="mt-8 pt-8 border-t">
<div className="flex flex-col items-center">
<p className="text-sm text-muted-foreground mb-4">Share this article:</p>
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
variant="footer"
/>
</div>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
<div className="flex flex-wrap gap-2">
@@ -92,6 +212,17 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</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>
)
}
@@ -1,72 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
export function ArticlesComponent() {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<Link
key={article.id}
to={`/articles/${article.id}` as any}
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">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</Link>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
}
@@ -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>
);
}
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api'
import { SocialShareButtons } from '@/components/features/social-share'
import { PinnedLiveBlogsSidebar } from '@/components/home/PinnedLiveBlogsSidebar'
interface CategoryPageProps {
categorySlug: string
categoryName: string
categoryDescription?: string
}
export function CategoryPage({ categorySlug, categoryName, categoryDescription }: CategoryPageProps) {
const { data: articlesData, isLoading: articlesLoading, error: articlesError } = useQuery({
queryKey: ['category-articles', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published' }),
})
const { data: heroData, isLoading: heroLoading } = useQuery({
queryKey: ['category-hero', categorySlug],
queryFn: () => api.fetchArticles({ category: categorySlug, status: 'published', limit: 1 }),
enabled: !!categorySlug,
})
const heroArticle = heroData?.data?.[0]
const articles = articlesData?.data || []
if (articlesLoading || heroLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Вчитување...</div>
</div>
)
}
if (articlesError) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Грешка при вчитување на статии</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Category Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold">{categoryName}</h1>
{categoryDescription && (
<p className="text-muted-foreground mt-2">{categoryDescription}</p>
)}
</div>
{/* Hero Section with Pinned Live Blogs Sidebar */}
{heroArticle && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
{/* Hero Article - 2/3 width */}
<div className="lg:col-span-2">
<div className="rounded-xl border bg-card overflow-hidden group hover:shadow-lg transition-shadow">
<Link to={`/articles/${heroArticle.id}`} className="block">
{heroArticle.featuredImage ? (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={heroArticle.featuredImage}
alt={heroArticle.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
) : (
<div className="h-64 md:h-80 bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-primary/30">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
)}
</Link>
<div className="p-6">
<Link to={`/articles/${heroArticle.id}`} className="block">
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors">
{heroArticle.title}
</h2>
{heroArticle.excerpt && (
<p className="text-muted-foreground mb-4 line-clamp-3">
{heroArticle.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(heroArticle.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{heroArticle.views} прегледи</span>
</div>
<SocialShareButtons
articleId={heroArticle.id}
title={heroArticle.title}
url={`${window.location.origin}/articles/${heroArticle.id}`}
excerpt={heroArticle.excerpt}
image={heroArticle.featuredImage}
tags={heroArticle.tags}
variant="compact"
/>
</div>
</div>
</div>
</div>
{/* Pinned Live Blogs Sidebar - 1/3 width */}
<div className="lg:col-span-1">
<PinnedLiveBlogsSidebar />
</div>
</div>
)}
{/* Articles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles
.filter(article => article.id !== heroArticle?.id) // Exclude hero article from grid
.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow group"
>
<Link
to={`/articles/${article.id}`}
className="block mb-4"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2 group-hover:text-primary transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
</Link>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{article.views} прегледи</span>
</div>
<SocialShareButtons
articleId={article.id}
title={article.title}
url={`${window.location.origin}/articles/${article.id}`}
excerpt={article.excerpt}
image={article.featuredImage}
tags={article.tags}
variant="compact"
/>
</div>
</div>
))}
</div>
{articles.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Нема објавени статии во оваа категорија. Проверете подоцна!
</p>
</div>
)}
</div>
)
}
@@ -38,7 +38,7 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => (
<Link
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"
>
<div className="flex items-start justify-between mb-4">
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function ScienceComponent() {
return (
<CategoryPage
categorySlug="science"
categoryName="Наука"
categoryDescription="Научни откритија и технологија"
/>
)
}
@@ -0,0 +1,11 @@
import { CategoryPage } from './CategoryPage'
export function SportComponent() {
return (
<CategoryPage
categorySlug="sport"
categoryName="Спорт"
categoryDescription="Спортски вести и анализи"
/>
)
}
@@ -0,0 +1,69 @@
/* eslint-disable react-refresh/only-export-components */
import { Article } from '@/lib/api';
interface SocialMetaTagsProps {
article: Article;
url?: string;
}
export function SocialMetaTags({ article, url }: SocialMetaTagsProps) {
// This component doesn't render anything directly
// It's used to generate meta tags for TanStack Router's head API
// Mark props as used to avoid ESLint warnings
void article;
void url;
return null;
}
export function getSocialMetaTags(article: Article, url?: string) {
// Use article's social metadata if available, otherwise generate from article data
const ogTitle = article.ogTitle || article.title;
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk';
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg';
const twitterTitle = article.twitterTitle || article.title;
const twitterDescription = article.twitterDescription || article.excerpt || 'Latest news from Placebo.mk';
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg';
// Use provided URL or construct from article ID
const articleUrl = url || `${typeof window !== 'undefined' ? window.location.origin : ''}/articles/${article.id}`;
const metaTags = [
// Basic SEO
{ title: `${article.title} - Placebo.mk` },
{ name: 'description', content: ogDescription },
// Open Graph tags
{ property: 'og:title', content: ogTitle },
{ property: 'og:description', content: ogDescription },
{ property: 'og:image', content: ogImage },
{ property: 'og:url', content: articleUrl },
{ property: 'og:type', content: 'article' },
{ property: 'og:locale', content: 'mk_MK' },
// Twitter Card tags
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: twitterTitle },
{ name: 'twitter:description', content: twitterDescription },
{ name: 'twitter:image', content: twitterImage },
// Article-specific tags
{ property: 'article:published_time', content: article.createdAt },
{ property: 'article:modified_time', content: article.updatedAt },
];
// Add author if available
if (article.author?.name) {
metaTags.push({ property: 'article:author', content: article.author.name });
}
// Add tags if available
if (article.tags && article.tags.length > 0) {
article.tags.forEach(tag => {
metaTags.push({ property: 'article:tag', content: tag });
});
}
return {
meta: metaTags,
};
}
@@ -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;
}
+8 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
export interface LiveBlogStreamEvent {
type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error';
@@ -16,13 +16,13 @@ export function useLiveBlogStream(
liveBlogId: string,
options: LiveBlogStreamOptions = {}
) {
const defaultOptions: LiveBlogStreamOptions = {
const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
autoReconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 10,
};
}), []);
const mergedOptions = { ...defaultOptions, ...options };
const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
@@ -124,14 +124,14 @@ export function useLiveBlogStream(
setConnectionError('Connection to live blog lost');
// 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;
setReconnectAttempts(nextAttempt);
reconnectTimeoutRef.current = setTimeout(() => {
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts})`);
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
createConnection();
}, optionsRef.current.reconnectInterval);
} else if (reconnectAttemptsRef.current >= optionsRef.current.maxReconnectAttempts) {
}, optionsRef.current.reconnectInterval || 3000);
} else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
setConnectionError('Failed to reconnect after multiple attempts');
}
};
+101
View File
@@ -0,0 +1,101 @@
import { useState, useCallback } from 'react';
import { type SharePlatform, type ShareData, getShareUrl, copyToClipboard } from '@/lib/social-utils';
import { trackShare } from '@/lib/analytics';
interface UseSocialShareOptions {
onSuccess?: (platform: SharePlatform) => void;
onError?: (platform: SharePlatform, error: Error) => void;
onCopySuccess?: () => void;
onCopyError?: (error: Error) => void;
}
interface UseSocialShareReturn {
isSharing: boolean;
isCopying: boolean;
lastSharedPlatform: SharePlatform | null;
share: (platform: SharePlatform, data: ShareData & { articleId: string }) => Promise<void>;
copyLink: (url: string) => Promise<boolean>;
}
export function useSocialShare(options: UseSocialShareOptions = {}): UseSocialShareReturn {
const [isSharing, setIsSharing] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const [lastSharedPlatform, setLastSharedPlatform] = useState<SharePlatform | null>(null);
const share = useCallback(async (
platform: SharePlatform,
data: ShareData & { articleId: string }
) => {
setIsSharing(true);
setLastSharedPlatform(platform);
try {
// Track the share event
await trackShare({
articleId: data.articleId,
platform,
userAgent: navigator.userAgent,
});
// Call success callback
if (options.onSuccess) {
options.onSuccess(platform);
}
// Open share URL for social platforms (not for 'link')
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, data);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} catch (error) {
console.error(`Failed to share on ${platform}:`, error);
// Call error callback
if (options.onError) {
options.onError(platform, error as Error);
}
// Still open the share URL even if tracking fails (for social platforms)
if (platform !== 'link') {
const shareUrl = getShareUrl(platform, data);
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
} finally {
setIsSharing(false);
}
}, [options]);
const copyLink = useCallback(async (url: string): Promise<boolean> => {
setIsCopying(true);
try {
const success = await copyToClipboard(url);
if (success && options.onCopySuccess) {
options.onCopySuccess();
} else if (!success && options.onCopyError) {
options.onCopyError(new Error('Failed to copy to clipboard'));
}
return success;
} catch (error) {
console.error('Failed to copy link:', error);
if (options.onCopyError) {
options.onCopyError(error as Error);
}
return false;
} finally {
setIsCopying(false);
}
}, [options]);
return {
isSharing,
isCopying,
lastSharedPlatform,
share,
copyLink,
};
}
+96
View File
@@ -0,0 +1,96 @@
import { type SharePlatform } from './social-utils';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export interface TrackShareParams {
articleId: string;
platform: SharePlatform;
userAgent?: string;
ipAddress?: string;
}
export const trackShare = async (params: TrackShareParams): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
if (!response.ok) {
console.warn('Failed to track share event:', response.statusText);
return false;
}
return true;
} catch (error) {
console.error('Error tracking share event:', error);
return false;
}
};
export const getShareStats = async (articleId?: string) => {
try {
const url = articleId
? `${API_BASE_URL}/api/v1/analytics/shares?articleId=${articleId}`
: `${API_BASE_URL}/api/v1/analytics/shares`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Include cookies for admin auth
});
if (!response.ok) {
throw new Error(`Failed to fetch share stats: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching share stats:', error);
throw error;
}
};
export const getTopSharedArticles = async (limit: number = 10) => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/top?limit=${limit}`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch top shared articles: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching top shared articles:', error);
throw error;
}
};
export const getTotalShareStats = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/analytics/shares/total`, {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch total share stats: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching total share stats:', error);
throw error;
}
};
+477 -30
View File
@@ -1,5 +1,45 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
// Debug logging
console.log('API_BASE_URL:', API_BASE_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 {
id: string;
title: string;
@@ -7,6 +47,11 @@ export interface Article {
excerpt: string | null;
slug: string;
featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
tags: string[];
status: 'draft' | 'published' | 'archived';
views: number;
@@ -41,12 +86,48 @@ export interface FindArticlesParams {
category?: string;
author?: string;
tag?: string;
status?: 'draft' | 'published' | 'archived';
status?: string;
search?: string;
page?: 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> {
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
const searchParams = new URLSearchParams();
@@ -65,7 +146,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
const url = `${API_BASE_URL}/articles?${searchParams}`;
console.log('Fetching from:', url);
const response = await fetch(url);
const response = await authFetch(url);
console.log('Response status:', response.status, 'ok:', response.ok);
if (!response.ok) {
@@ -78,7 +159,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
}
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) {
throw new Error('Failed to fetch article');
}
@@ -86,13 +167,64 @@ export async function fetchArticleBySlug(slug: 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) {
throw new Error('Failed to fetch article');
}
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
export interface LiveBlogUpdate {
id: string;
@@ -122,6 +254,12 @@ export interface LiveBlog {
strapiId: string | null;
authorId: 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;
author?: {
id: string;
@@ -158,7 +296,7 @@ export interface LiveBlogUpdatesResponse {
export interface FindLiveBlogsParams {
category?: string;
author?: string;
status?: 'draft' | 'live' | 'ended' | 'archived';
status?: string;
search?: string;
page?: number;
limit?: number;
@@ -215,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) {
throw new Error('Failed to fetch live blogs');
}
@@ -223,7 +361,7 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
}
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) {
throw new Error('Failed to fetch live blog');
}
@@ -231,7 +369,7 @@ export async function fetchLiveBlogBySlug(slug: 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) {
throw new Error('Failed to fetch live blog');
}
@@ -243,7 +381,7 @@ export async function fetchLiveBlogUpdates(
page = 1,
limit = 50
): Promise<LiveBlogUpdatesResponse> {
const response = await fetch(
const response = await authFetch(
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
);
if (!response.ok) {
@@ -253,15 +391,32 @@ export async function fetchLiveBlogUpdates(
}
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) {
throw new Error('Failed to fetch recent live blogs');
}
return response.json();
}
export async function fetchHeroArticle(): Promise<Article | null> {
const response = await authFetch(`${API_BASE_URL}/articles/hero`);
if (!response.ok) {
throw new Error('Failed to fetch hero article');
}
const article = await response.json();
return article || null;
}
export async function fetchLatestArticles(limit = 12): Promise<ArticlesResponse> {
const response = await authFetch(`${API_BASE_URL}/articles?status=published&limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to fetch latest articles');
}
return response.json();
}
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) {
throw new Error('Failed to fetch pinned live blogs');
}
@@ -269,7 +424,7 @@ export async function fetchPinnedLiveBlogs(): 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) {
throw new Error('Failed to fetch active live blogs');
}
@@ -281,11 +436,8 @@ export async function createLiveBlogUpdate(
liveBlogId: string,
dto: CreateLiveBlogUpdateDto
): 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',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
@@ -299,11 +451,8 @@ export async function updateLiveBlogUpdate(
updateId: string,
dto: UpdateLiveBlogUpdateDto
): 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',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
@@ -313,7 +462,7 @@ export async function updateLiveBlogUpdate(
}
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',
});
if (!response.ok) {
@@ -322,11 +471,8 @@ export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string)
}
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',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
@@ -336,11 +482,8 @@ export async function createLiveBlog(dto: CreateLiveBlogDto): 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',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
@@ -350,10 +493,314 @@ export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promis
}
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',
});
if (!response.ok) {
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;
};
replies?: Comment[];
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
// Interface for backend comment response
interface BackendComment {
id: string;
content: string;
articleId: string | null;
liveBlogId: string | null;
parentId: string | null;
userId: string;
likeCount: number;
dislikeCount: number;
isVisible: boolean;
createdAt: string;
updatedAt: string;
user?: {
id: string;
username: string;
};
replies?: BackendComment[];
}
// Recursive function to map comment and its replies
function mapBackendComment(comment: BackendComment): Comment {
const mappedComment: Comment = {
...comment,
parentCommentId: comment.parentId,
// Ensure reactions object exists
reactions: {
likes: comment.likeCount || 0,
dislikes: comment.dislikeCount || 0,
},
// Recursively map replies if they exist
replies: comment.replies?.map(mapBackendComment) || [],
};
return mappedComment;
}
export async function fetchComments(params: FindCommentsParams = {}): Promise<CommentsResponse> {
const searchParams = new URLSearchParams();
// Map parentCommentId to parentId for backend compatibility
const backendParams = { ...params };
if (backendParams.parentCommentId) {
backendParams.parentId = backendParams.parentCommentId;
delete backendParams.parentCommentId;
}
Object.entries(backendParams).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');
}
const data = await response.json();
const mappedData = (data as BackendComment[]).map(mapBackendComment);
return {
data: mappedData,
total: mappedData.length,
};
}
export async function createComment(dto: CreateCommentDto): Promise<Comment> {
// Map parentCommentId to parentId for backend compatibility
const backendDto = {
content: dto.content,
articleId: dto.articleId,
liveBlogId: dto.liveBlogId,
parentId: dto.parentCommentId,
};
const response = await authFetch(`${API_BASE_URL}/comments`, {
method: 'POST',
body: JSON.stringify(backendDto),
});
if (!response.ok) {
throw new Error('Failed to create comment');
}
const comment = await response.json() as BackendComment;
// Map backend response to frontend interface
return mapBackendComment(comment);
}
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();
}
+124
View File
@@ -0,0 +1,124 @@
export type SharePlatform = 'facebook' | 'twitter' | 'whatsapp' | 'telegram' | 'email' | 'link';
export interface ShareData {
title: string;
url: string;
excerpt?: string;
image?: string;
tags?: string[];
}
export const getShareUrl = (
platform: Exclude<SharePlatform, 'link'>,
data: ShareData
): string => {
const { title, url, excerpt } = data;
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
const encodedText = encodeURIComponent(excerpt ? `${title} - ${excerpt}` : title);
switch (platform) {
case 'facebook':
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
case 'twitter':
return `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
case 'whatsapp':
return `https://wa.me/?text=${encodedText}%20${encodedUrl}`;
case 'telegram':
return `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`;
case 'email':
return `mailto:?subject=${encodedTitle}&body=${encodedUrl}`;
default:
return url;
}
};
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
return success;
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
return false;
}
};
export const getPlatformIcon = (platform: SharePlatform): string => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'twitter':
return 'Twitter';
case 'whatsapp':
return 'MessageCircle';
case 'telegram':
return 'Send';
case 'email':
return 'Mail';
case 'link':
return 'Link';
default:
return 'Share2';
}
};
export const getPlatformLabel = (platform: SharePlatform): string => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'twitter':
return 'Twitter';
case 'whatsapp':
return 'WhatsApp';
case 'telegram':
return 'Telegram';
case 'email':
return 'Email';
case 'link':
return 'Copy Link';
default:
return 'Share';
}
};
export const generateSocialMetaTags = (data: ShareData & { articleId: string }) => {
const { title, excerpt, image, url } = data;
// Default values if not provided
const ogTitle = title || 'Placebo.mk - Sarcastic News from Macedonia';
const ogDescription = excerpt || 'Latest news and articles from Macedonia with a sarcastic twist';
const ogImage = image || '/placeholder-image.jpg';
const ogUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
return {
ogTitle,
ogDescription,
ogImage,
ogUrl,
ogType: 'article' as const,
twitterCard: 'summary_large_image' as const,
twitterTitle: ogTitle,
twitterDescription: ogDescription,
twitterImage: ogImage,
};
};
export const truncateForTwitter = (text: string, maxLength: number = 280): string => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
};
+79
View File
@@ -0,0 +1,79 @@
/**
* Theme utilities for dark/light mode
*/
export type Theme = 'light' | 'dark';
/**
* Get the current theme from localStorage or system preference
*/
export function getInitialTheme(): Theme {
// Check localStorage first
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// Default to light
return 'light';
}
/**
* Apply theme to document
*/
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Save to localStorage
localStorage.setItem('theme', theme);
}
/**
* Initialize theme on page load
*/
export function initializeTheme(): void {
const theme = getInitialTheme();
applyTheme(theme);
}
/**
* Toggle between light and dark themes
*/
export function toggleTheme(): Theme {
const currentTheme = getInitialTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
return newTheme;
}
/**
* Listen for system theme changes
*/
export function watchSystemTheme(callback: (theme: Theme) => void): () => void {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a preference in localStorage
if (!localStorage.getItem('theme')) {
const theme = e.matches ? 'dark' : 'light';
callback(theme);
applyTheme(theme);
}
};
mediaQuery.addEventListener('change', handleChange);
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange);
}
+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 };
}
+8 -1
View File
@@ -3,13 +3,20 @@ import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme'
// Initialize theme before rendering
initializeTheme();
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</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';
export function useArticles(params: api.FindArticlesParams = {}) {
@@ -23,3 +23,68 @@ export function useArticleById(id: string) {
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
@@ -155,4 +155,31 @@ export function useDeleteLiveBlog() {
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
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'] });
},
});
}
+231 -151
View File
@@ -1,14 +1,22 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { ArticleTicker } from './components/ArticleTicker'
import { ArticlesComponent } from './components/routes/ArticlesComponent'
import { ArchiveComponent } from './components/routes/ArchiveComponent'
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { AuthPage } from './components/routes/AuthPage'
import { SportComponent } from './components/routes/SportComponent'
import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar'
import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { Header } from './components/layout/Header'
import { HeroArticle } from './components/home/HeroArticle'
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
import './styles.css'
const rootRoute = createRootRoute({
@@ -22,30 +30,7 @@ const rootRoute = createRootRoute({
}),
component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col">
<header className="border-b">
<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>
<Header />
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
<Outlet />
@@ -73,131 +58,117 @@ const indexRoute = createRoute({
<div className="py-8 md:py-12">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Main content - 3 columns */}
<div className="lg:col-span-3 space-y-8">
{/* Hero section */}
<div className="rounded-xl border bg-card p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
Placebo<span className="text-primary">.mk</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
</p>
</div>
{/* Hero Section with Pinned Live Blogs Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
{/* Hero Article - 2/3 width */}
<div className="lg:col-span-2">
<HeroArticle />
</div>
{/* Pinned Live Blogs Sidebar - 1/3 width */}
<div className="lg:col-span-1">
<PinnedLiveBlogsSidebar />
</div>
</div>
{/* Features grid */}
<div className="grid md:grid-cols-3 gap-6">
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
<p className="text-muted-foreground text-sm">
Freshly brewed sarcasm on current events, politics, and everything in between.
</p>
</div>
{/* Latest Articles Grid - 4x3 */}
<div className="mb-12">
<LatestArticlesGrid />
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
<p className="text-muted-foreground text-sm">
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
</p>
</div>
{/* Brand Introduction */}
<div className="rounded-xl border bg-card p-8 text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
Placebo<span className="text-primary">.mk</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
<p className="text-muted-foreground text-sm">
Real-time updates on breaking news with our live blogging system. No delays, just facts.
</p>
</div>
</div>
{/* Call to action */}
<div className="rounded-xl border bg-card p-8 text-center">
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
Dive into our articles or follow live coverage of breaking news as it happens.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/articles"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
>
Read Articles
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
<Link
to="/live-blogs"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
>
View Live Blogs
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</Link>
</div>
{/* Features grid */}
<div className="grid md:grid-cols-3 gap-6 mb-12">
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
<p className="text-muted-foreground text-sm">
Freshly brewed sarcasm on current events, politics, and everything in between.
</p>
</div>
{/* Sidebar - 1 column */}
<div className="lg:col-span-1">
<PinnedLiveBlogSidebar />
{/* Additional sidebar content */}
<div className="mt-6 rounded-xl border bg-card p-6">
<h3 className="font-semibold mb-4">About Placebo.mk</h3>
<p className="text-sm text-muted-foreground mb-4">
We're not here to make friends. We're here to tell the truth with a healthy dose of sarcasm.
</p>
<div className="text-xs text-muted-foreground">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Live coverage updated in real-time</span>
</div>
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span>No ads, no sponsors, no BS</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span>100% independent journalism</span>
</div>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
<p className="text-muted-foreground text-sm">
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Live Coverage</h3>
<p className="text-muted-foreground text-sm">
Real-time updates on breaking news with our live blogging system. No delays, just facts.
</p>
</div>
</div>
{/* Call to action */}
<div className="rounded-xl border bg-card p-8 text-center">
<h2 className="text-2xl font-bold mb-4">Ready for some unfiltered truth?</h2>
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
Dive into our articles or follow live coverage of breaking news as it happens.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/archive"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-6 py-2"
>
Прелистај архива
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
<Link
to="/live-blogs"
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-6 py-2"
>
View Live Blogs
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ml-2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</Link>
</div>
</div>
</div>
@@ -206,10 +177,28 @@ const indexRoute = createRoute({
),
})
const articlesRoute = createRoute({
const archiveRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/articles',
component: ArticlesComponent,
path: '/archive',
component: ArchiveComponent,
})
const sportRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sport',
component: SportComponent,
})
const artRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/art',
component: ArtComponent,
})
const scienceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/science',
component: ScienceComponent,
})
const articleDetailRoute = createRoute({
@@ -219,6 +208,75 @@ const articleDetailRoute = createRoute({
const { id } = articleDetailRoute.useParams()
return <ArticleDetailComponent id={id} />
},
loader: async ({ params }) => {
// Fetch article data for meta tags
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/v1/articles/${params.id}`)
if (!response.ok) {
return { article: null }
}
const data = await response.json()
return { article: data.data }
},
head: ({ loaderData }) => {
const article = loaderData?.article
if (!article) {
return {
meta: [
{ title: 'Article Not Found - Placebo.mk' },
{ name: 'description', content: 'Article not found' },
],
}
}
// Use article's social metadata if available, otherwise generate from article data
const ogTitle = article.ogTitle || article.title
const ogDescription = article.ogDescription || article.excerpt || 'Latest news from Placebo.mk'
const ogImage = article.ogImage || article.featuredImage || '/placeholder-image.jpg'
const twitterTitle = article.twitterTitle || article.title
const twitterDescription = article.twitterDescription || article.excerpt || 'Latest news from Placebo.mk'
const twitterImage = article.twitterImage || article.featuredImage || '/placeholder-image.jpg'
const metaTags = [
// Basic SEO
{ title: `${article.title} - Placebo.mk` },
{ name: 'description', content: ogDescription },
// Open Graph tags
{ property: 'og:title', content: ogTitle },
{ property: 'og:description', content: ogDescription },
{ property: 'og:image', content: ogImage },
{ property: 'og:url', content: typeof window !== 'undefined' ? window.location.href : '' },
{ property: 'og:type', content: 'article' },
{ property: 'og:locale', content: 'mk_MK' },
// Twitter Card tags
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: twitterTitle },
{ name: 'twitter:description', content: twitterDescription },
{ name: 'twitter:image', content: twitterImage },
// Article-specific tags
{ property: 'article:published_time', content: article.createdAt },
{ property: 'article:modified_time', content: article.updatedAt },
]
// Add author if available
if (article.author?.name) {
metaTags.push({ property: 'article:author', content: article.author.name })
}
// Add tags if available
if (article.tags && article.tags.length > 0) {
article.tags.forEach(tag => {
metaTags.push({ property: 'article:tag', content: tag })
})
}
return {
meta: metaTags,
}
},
})
const liveBlogsRoute = createRoute({
@@ -236,33 +294,55 @@ const liveBlogDetailRoute = createRoute({
},
})
const authRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth',
component: AuthPage,
})
const liveBlogAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin/live-blogs/$slug',
component: () => {
const { slug } = liveBlogAdminRoute.useParams()
return <LiveBlogAdminComponent slug={slug} />
return (
<ProtectedRoute requiredRole="admin">
<LiveBlogAdminComponent slug={slug} />
</ProtectedRoute>
)
},
})
const createLiveBlogRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin/live-blogs/create',
component: CreateLiveBlogComponent,
component: () => (
<ProtectedRoute requiredRole="admin">
<CreateLiveBlogComponent />
</ProtectedRoute>
),
})
const adminDashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
component: AdminDashboardComponent,
component: () => (
<ProtectedRoute requiredRole="admin">
<AdminDashboardComponent />
</ProtectedRoute>
),
})
const routeTree = rootRoute.addChildren([
indexRoute,
articlesRoute,
archiveRoute,
sportRoute,
artRoute,
scienceRoute,
articleDetailRoute,
liveBlogsRoute,
liveBlogDetailRoute,
authRoute,
liveBlogAdminRoute,
createLiveBlogRoute,
adminDashboardRoute,
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more