Author SHA1 Message Date
dimitar 8510cccb6e buymeacoffe added 2026-02-16 21:20:58 +01:00
dimitar c2c77ac92a Merge branch 'redesignNo1' 2026-02-16 20:45:29 +01:00
dimitar 71b1b549c3 admin dashboard fixed
maybe we need another design for this page
2026-02-16 20:42:11 +01:00
dimitar aa79eba06d pading fixed on pages 2026-02-16 19:52:37 +01:00
dimitar 62c68e4bc6 scrollbar on every page 2026-02-16 19:47:24 +01:00
dimitar 2be6216772 date fixed
also прекршени вести Ч)
2026-02-16 19:41:00 +01:00
dimitar ae28f56337 header transparency fix 2026-02-16 19:19:27 +01:00
dimitar 10fe702749 liveblog update fix 2026-02-16 19:15:31 +01:00
dimitar 4c4d741b1f redesign looking good 2026-02-16 18:50:33 +01:00
dimitar 6abae13dbd run 2026-02-16 18:16:06 +01:00
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
104 changed files with 9299 additions and 1602 deletions

No files matched your search

+77 -10
View File
@@ -8,6 +8,34 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
- **Backend**: NestJS + TypeORM + SQLite - **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi (in /cms) - **CMS**: Strapi (in /cms)
## Root-Level Commands
```bash
# Docker development
npm run docker:up # Start all services
npm run docker:down # Stop all services
npm run docker:build # Rebuild containers
npm run docker:logs # View logs
npm run dev:docker # Start docker dev (alias)
# Local development
npm run dev:local # Start backend & frontend locally
npm run dev:backend:local # Backend only (local env)
npm run dev:frontend:local # Frontend only (local env)
# Code quality
npm run lint # Lint both projects
npm run lint:fix # Auto-fix lint issues
npm run type-check # Type check both projects
# Database operations
npm run db:backup # Backup database
npm run db:restore # Restore database
npm run db:reset # Reset database
# Environment
npm run reset:env # Reset to docker environment
```
## Build Commands ## Build Commands
### Backend (NestJS) ### Backend (NestJS)
@@ -15,16 +43,21 @@ News site in Macedonia with sarcastic tone. Minimalistic design using TanStack s
cd backend cd backend
npm install npm install
npm run start:dev # Watch mode npm run start:dev # Watch mode
npm run start:debug # Debug mode
npm run build npm run build
npm run start:prod npm run start:prod
npm run lint # Lint npm run lint # Lint
npm run lint:fix # Auto-fix npm run lint:fix # Auto-fix
npm run type-check # TypeScript check npm run type-check # TypeScript check
npm test # All tests npm test # All tests
npm test:watch # Watch mode tests
npm test:cov # Coverage
npm test:e2e # E2E tests
npm test app.service.spec.ts # Single file npm test app.service.spec.ts # Single file
npm test -t "should" # By test name npm test -t "should return" # By test name pattern
npm run test:cov # Coverage npm run format # Format with Prettier
npm run test:e2e # E2E tests npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
``` ```
### Frontend (TanStack) ### Frontend (TanStack)
@@ -34,13 +67,16 @@ npm install
npm run dev # Vite dev server npm run dev # Vite dev server
npm run build npm run build
npm run preview npm run preview
npm run lint npm run lint # Lint
npm run lint:fix npm run lint:fix # Auto-fix
npm run type-check npm run type-check # TypeScript check
npm test # All tests npm test # All tests (Vitest)
npm test:ui # Vitest UI
npm test:coverage # Test coverage
npm test Header.test.tsx # Single file npm test Header.test.tsx # Single file
npm test -t "renders" # By name npm test -t "renders" # By test name pattern
npm run test:ui # Vitest UI npm run dev:local # Local env with .env.local
npm run dev:docker # Docker env with .env.docker
``` ```
## Code Style ## Code Style
@@ -51,6 +87,8 @@ npm run test:ui # Vitest UI
- Explicit return types for public methods - Explicit return types for public methods
- Prefer interfaces over types for objects - Prefer interfaces over types for objects
- Use `readonly` for immutable properties - Use `readonly` for immutable properties
- Backend: `noImplicitAny: true`, `strictNullChecks: true`
- Frontend: Path alias `@/*` maps to `./src/*`
### Naming Conventions ### Naming Conventions
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`) - **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
@@ -62,6 +100,20 @@ npm run test:ui # Vitest UI
### Import Order ### Import Order
- External libraries first, then internal modules, then relative imports - External libraries first, then internal modules, then relative imports
- Group imports with blank lines between groups
- Example:
```typescript
// External
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Internal modules
import { AuthModule } from '../auth/auth.module';
// Relative imports
import { UserService } from './user.service';
import { UserController } from './user.controller';
```
### File Structure ### File Structure
@@ -104,12 +156,16 @@ frontend/src/
``` ```
### Testing ### Testing
- **Backend**: Jest with ts-jest transformer
- **Frontend**: Vitest with React Testing Library patterns
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert) - Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
- Integration tests for API endpoints - Integration tests for API endpoints
- E2E tests for critical user flows - E2E tests for critical user flows
- Descriptive test names that explain what is tested - Descriptive test names that explain what is tested (e.g., `should return user data when valid ID provided`)
- Mock external dependencies (use Jest for backend, Vitest for frontend) - Mock external dependencies (use Jest for backend, Vitest for frontend)
- Run `npm test` for all tests or target specific files/names - Run `npm test` for all tests or target specific files/names
- Test files: `*.spec.ts` (backend), `*.test.tsx` (frontend)
- Coverage reports: `npm test:cov` (backend), `npm test:coverage` (frontend)
### Git Workflow ### Git Workflow
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` - Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
@@ -147,5 +203,16 @@ frontend/src/
- Example backend env: `DATABASE_PATH=./data.db` - Example backend env: `DATABASE_PATH=./data.db`
- Store sensitive config in `.env` files - Store sensitive config in `.env` files
- Use `DATABASE_PATH` for SQLite location - Use `DATABASE_PATH` for SQLite location
- Use `@nestjs/config` for backend environment management
- Frontend env vars must be prefixed with `VITE_`
## Agent-Specific Instructions
- **ALWAYS** run `npm run lint` and `npm run type-check` after making changes
- **NEVER** commit changes without explicit user request
- **ALWAYS** follow existing code patterns and conventions
- **PREFER** editing existing files over creating new ones
- **VERIFY** tests pass before considering work complete
- **USE** the root-level commands for common operations
- **CHECK** both backend and frontend when making API changes
+70
View File
@@ -0,0 +1,70 @@
# Quick Strapi Webhook Setup
## TL;DR - Quick Configuration
1. **Access Strapi Admin**: `http://localhost:1337/admin`
2. **Go to Settings → Webhooks**
3. **Add New Webhook**:
- **Name**: `Backend Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
- **Events**: Select all for "Article" and "Live Blog" content types
4. **Save and Test**
## Already Configured (What We Fixed)
✅ **Backend webhook endpoints** are now public (no auth required)
✅ **Tested webhooks** manually - they work
✅ **Articles sync** from Strapi to backend
✅ **Frontend TypeScript errors** fixed
✅ **Authentication system** working
## Manual Test Commands
```bash
# Test webhook manually
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
-H "Content-Type: application/json" \
-d '{
"event": "entry.publish",
"model": "article",
"entry": {"documentId": "r07qatlpgvx82d7337n3nz1l"}
}'
# Check synced articles
curl http://localhost:3000/api/v1/articles?status=published
# Run comprehensive test
./scripts/test-webhooks.sh
```
## Current Status
- **Strapi Articles**: 2
- **Backend Articles**: 2 (synced)
- **Webhook Status**: Ready for configuration
- **Frontend**: Access at `http://localhost:5173/articles`
## Immediate Action Required
1. **Configure webhooks in Strapi admin** (see detailed guide in `STRAPI_WEBHOOKS_SETUP.md`)
2. **Test by publishing an article** in Strapi
3. **Verify automatic sync** works
## If Webhooks Don't Work
Use manual sync as fallback:
```bash
# Get auth token first
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"Test123!"}' | jq -r '.access_token')
# Sync all articles
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
-H "Authorization: Bearer $TOKEN"
```
## Verification
- Visit `http://localhost:5173/articles` to see synced articles
- Check backend logs: `docker logs placebo-backend-dev --tail 20`
- Monitor sync status with test script: `./scripts/test-webhooks.sh`
+191
View File
@@ -0,0 +1,191 @@
# Strapi Webhooks Configuration Guide
## Overview
This guide explains how to configure Strapi webhooks for automatic synchronization with the backend API. When articles or live blogs are published/updated/deleted in Strapi, webhooks will automatically trigger synchronization with the backend.
## Webhook Endpoints
The backend provides three webhook endpoints:
1. **Article-specific webhook**: `POST /api/v1/webhooks/strapi/article`
2. **Live Blog-specific webhook**: `POST /api/v1/webhooks/strapi/live-blog`
3. **Generic webhook**: `POST /api/v1/webhooks/strapi` (handles both)
All endpoints accept the following JSON format:
```json
{
"event": "entry.publish", // or "entry.update", "entry.delete", "entry.unpublish"
"model": "article", // or "live-blog"
"entry": {
"documentId": "unique-strapi-id"
}
}
```
## Configuration Steps
### Step 1: Access Strapi Admin Panel
1. Open your browser and go to: `http://localhost:1337/admin`
2. Log in with your admin credentials
### Step 2: Configure Webhooks
1. In the left sidebar, click on **Settings** (gear icon)
2. Click on **Webhooks** under the "GLOBAL SETTINGS" section
3. Click the **"Add new webhook"** button
### Step 3: Configure Article Webhook
**Basic Settings:**
- **Name**: `Backend Article Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/article`
- For Docker internal communication: `http://backend:3000/api/v1/webhooks/strapi/article`
- **Headers**: Add if needed (usually not required)
**Trigger Events:**
Select the following events for the "Article" content type:
- [x] **Create entry** (`entry.create`)
- [x] **Update entry** (`entry.update`)
- [x] **Delete entry** (`entry.delete`)
- [x] **Publish entry** (`entry.publish`)
- [x] **Unpublish entry** (`entry.unpublish`)
**Save the webhook.**
### Step 4: Configure Live Blog Webhook (Optional)
Repeat Step 3 for live blogs:
- **Name**: `Backend Live Blog Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/live-blog`
- Select events for the "Live Blog" content type
### Alternative: Single Generic Webhook
You can use a single webhook for all content types:
- **Name**: `Backend Generic Sync`
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
- Select events for ALL relevant content types (Article, Live Blog)
## Testing Webhooks
### Manual Test
You can manually trigger a webhook to test the configuration:
```bash
# Test article webhook
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/article \
-H "Content-Type: application/json" \
-d '{
"event": "entry.publish",
"model": "article",
"entry": {
"documentId": "your-article-id"
}
}'
# Test generic webhook
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
-H "Content-Type: application/json" \
-d '{
"event": "entry.publish",
"model": "article",
"entry": {
"documentId": "your-article-id"
}
}'
```
### Test from Strapi
1. In Strapi admin, edit any article
2. Click "Save" or "Publish"
3. Check backend logs for webhook receipt:
```bash
docker logs placebo-backend-dev --tail 20 | grep -i "webhook\|strapi"
```
## Manual Sync (Fallback)
If webhooks fail or for initial sync, use manual sync endpoints:
```bash
# Get authentication token first
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"your-username","password":"your-password"}'
# Sync all articles (requires auth)
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Sync all live blogs
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/live-blogs \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Sync everything
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/everything \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
## Troubleshooting
### Common Issues
1. **Webhook not received by backend**
- Check backend logs: `docker logs placebo-backend-dev --tail 50`
- Verify CORS is configured (should allow `http://localhost:1337`)
- Test webhook manually with curl
2. **401 Unauthorized error**
- Webhook endpoints are now public (no auth required)
- If getting 401, check that `@Public()` decorator is on webhook methods
3. **Webhook received but sync fails**
- Check Strapi API token in backend `.env.docker`
- Verify Strapi is accessible from backend container
- Check backend logs for specific error messages
4. **Article not appearing in frontend**
- Verify article is synced to backend: `curl http://localhost:3000/api/v1/articles`
- Check frontend browser console for errors
- Hard refresh frontend (Ctrl+F5)
### Log Monitoring
```bash
# Monitor backend logs for webhooks
docker logs -f placebo-backend-dev | grep -i "webhook\|strapi"
# Monitor Strapi logs
docker logs -f placebo-cms-dev
# Check if articles are in backend
curl -s "http://localhost:3000/api/v1/articles?status=published" | jq '.total'
```
## Docker Network Considerations
### Internal Docker Communication
- Within Docker network: Use `http://backend:3000` and `http://cms:1337`
- From host machine: Use `http://localhost:3000` and `http://localhost:1337`
### Webhook URL Examples
- **From Strapi container to Backend**: `http://backend:3000/api/v1/webhooks/strapi`
- **From Host to Backend**: `http://localhost:3000/api/v1/webhooks/strapi`
- **External/Production**: Use your domain: `https://api.yourdomain.com/api/v1/webhooks/strapi`
## Security Considerations
1. **Webhook endpoints are public** - ensure your backend is not exposed to the public internet in production
2. **Consider adding webhook signature verification** for production
3. **Use HTTPS in production** for all webhook calls
4. **Monitor webhook logs** for suspicious activity
## Verification Checklist
- [ ] Webhooks configured in Strapi admin
- [ ] Test webhook manually works
- [ ] Article publish in Strapi triggers sync
- [ ] Synced articles appear in backend API
- [ ] Frontend displays synced articles
- [ ] All event types tested (publish, update, delete)
## Support
If issues persist:
1. Check all service logs
2. Verify network connectivity between containers
3. Test each component independently
4. Review error messages in logs
+3
View File
@@ -4,6 +4,9 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install build dependencies for native modules
RUN apk add --no-cache python3 make g++
# Install dependencies with better error handling # Install dependencies with better error handling
COPY package*.json ./ COPY package*.json ./
COPY package-lock.json* ./ COPY package-lock.json* ./
+638 -342
View File
File diff suppressed because it is too large. Load diff
+12 -1
View File
@@ -22,7 +22,9 @@
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"dev:docker": "nest start --watch", "dev:docker": "nest start --watch",
"dev:local": "cp -f .env.local .env && nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env" "dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts",
"seed:categories": "ts-node scripts/seed-categories.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
@@ -30,10 +32,19 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^6.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
+26
View File
@@ -0,0 +1,26 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
async function resetDatabase() {
console.log('Resetting database...');
try {
// Drop and recreate the database
const { stdout, stderr } = await execAsync(`
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "DROP DATABASE IF EXISTS placebo_backend_db;"
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "CREATE DATABASE placebo_backend_db;"
`);
if (stderr && !stderr.includes('warning')) {
console.error('Error:', stderr);
return;
}
console.log('Database reset successfully!');
} catch (error) {
console.error('Failed to reset database:', error.message);
}
}
resetDatabase();
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
echo "Resetting PostgreSQL database..."
# Connect to PostgreSQL and reset the database (without -t for TTY)
docker exec -i placebo-postgres-dev psql -U placebo_user -d postgres <<EOF
DROP DATABASE IF EXISTS placebo_backend_db;
CREATE DATABASE placebo_backend_db;
EOF
echo "Database reset complete!"
echo "The new schema will be created when the backend starts with DATABASE_SYNCHRONIZE=true"
+41
View File
@@ -0,0 +1,41 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { UserService } from '../src/modules/users/user.service';
import { UserRole } from '../src/modules/entities';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
const userService = app.get(UserService);
try {
// Check if admin user already exists
const existingAdmin = await userService.findByUsername('admin');
if (existingAdmin) {
console.log('Admin user already exists');
await app.close();
return;
}
// Create admin user
const adminUser = await userService.create({
username: 'admin',
email: 'admin@placebo.mk',
password: 'admin123', // Change this in production!
role: UserRole.ADMIN,
isActive: true,
});
console.log('Admin user created successfully:');
console.log(`Username: ${adminUser.username}`);
console.log(`Email: ${adminUser.email}`);
console.log(`Role: ${adminUser.role}`);
console.log('\n⚠️ IMPORTANT: Change the default password immediately!');
} catch (error) {
console.error('Error creating admin user:', error);
} finally {
await app.close();
}
}
bootstrap();
+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 { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.module'; import { StrapiModule } from './modules/strapi.module';
import { LiveBlogModule } from './modules/live-blog.module'; import { LiveBlogModule } from './modules/live-blog.module';
import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { import {
Article, Article,
Author, Author,
Category, Category,
LiveBlog, LiveBlog,
LiveBlogUpdate, LiveBlogUpdate,
User,
Comment,
Reaction,
} from './modules/entities'; } from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity';
@Module({ @Module({
imports: [ imports: [
@@ -26,13 +34,27 @@ import {
username: process.env.DATABASE_USERNAME || 'placebo_user', username: process.env.DATABASE_USERNAME || 'placebo_user',
password: process.env.DATABASE_PASSWORD || 'placebo_password', password: process.env.DATABASE_PASSWORD || 'placebo_password',
database: process.env.DATABASE_NAME || 'placebo_backend_db', database: process.env.DATABASE_NAME || 'placebo_backend_db',
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate], entities: [
synchronize: process.env.NODE_ENV !== 'production', Article,
logging: process.env.NODE_ENV === 'development', Author,
Category,
LiveBlog,
LiveBlogUpdate,
User,
Comment,
Reaction,
ShareEvent,
],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true',
}), }),
ArticlesModule, ArticlesModule,
StrapiModule, StrapiModule,
LiveBlogModule, LiveBlogModule,
UserModule,
AuthModule,
CommentModule,
AnalyticsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+16 -1
View File
@@ -1,5 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory, Reflector } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@@ -16,6 +18,19 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
// Apply global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Apply global authentication guard
const reflector = app.get(Reflector);
app.useGlobalGuards(new JwtAuthPublicGuard(reflector));
const port = process.env.PORT ?? 3000; const port = process.env.PORT ?? 3000;
const host = '0.0.0.0'; // Bind to all interfaces for Docker const host = '0.0.0.0'; // Bind to all interfaces for Docker
await app.listen(port, host); await app.listen(port, host);
@@ -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,
};
}
}
+31 -15
View File
@@ -8,7 +8,7 @@ import {
Body, Body,
Param, Param,
Query, Query,
ValidationPipe, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { import {
@@ -16,57 +16,73 @@ import {
UpdateArticleDto, UpdateArticleDto,
FindArticlesDto, FindArticlesDto,
} from './articles.dto'; } from './articles.dto';
import { ArticleStatus } 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';
import { UserRole } from './entities';
@Controller('articles') @Controller('articles')
export class ArticlesController { export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {} constructor(private readonly articlesService: ArticlesService) {}
@Post() @Post()
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) { @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
create(@Body() dto: CreateArticleDto) {
return this.articlesService.create(dto); return this.articlesService.create(dto);
} }
@Get() @Get()
findAll( @Public()
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto, findAll(@Query() dto: FindArticlesDto) {
) {
return this.articlesService.findAll(dto); return this.articlesService.findAll(dto);
} }
@Get('hero')
@Public()
findHero() {
return this.articlesService.findHeroArticle();
}
@Get(':id') @Get(':id')
@Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.articlesService.findOne(id); return this.articlesService.findOne(id);
} }
@Get('slug/:slug') @Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) { findBySlug(@Param('slug') slug: string) {
return this.articlesService.findBySlug(slug); return this.articlesService.findBySlug(slug);
} }
@Put(':id') @Put(':id')
update( @UseGuards(JwtAuthGuard, RolesGuard)
@Param('id') id: string, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto, update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
) {
return this.articlesService.update(id, dto); return this.articlesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.articlesService.remove(id); return this.articlesService.remove(id);
} }
@Patch(':id/archive') @Patch(':id/archive')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
archive(@Param('id') id: string) { archive(@Param('id') id: string) {
return this.articlesService.archive(id); return this.articlesService.archive(id);
} }
@Patch(':id/publish') @Patch(':id/publish')
publish( @UseGuards(JwtAuthGuard, RolesGuard)
@Param('id') id: string, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Query('status') status?: ArticleStatus, publish(@Param('id') id: string) {
) { return this.articlesService.publish(id);
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
} }
} }
+103 -35
View File
@@ -6,7 +6,7 @@ import {
IsUUID, IsUUID,
IsNumber, IsNumber,
IsBoolean, IsBoolean,
IsDate, IsDateString,
} from 'class-validator'; } from 'class-validator';
import { import {
ArticleStatus, ArticleStatus,
@@ -48,6 +48,10 @@ export class CreateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -75,6 +79,30 @@ export class CreateArticleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
videoCaption?: string; 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 { export class UpdateArticleDto {
@@ -111,6 +139,10 @@ export class UpdateArticleDto {
@IsString() @IsString()
strapiId?: string; strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
authorId?: string; authorId?: string;
@@ -138,6 +170,30 @@ export class UpdateArticleDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
videoCaption?: string; 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 { export class FindArticlesDto {
@@ -169,6 +225,35 @@ export class FindArticlesDto {
limit?: number; limit?: number;
} }
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;
}
export class CreateLiveBlogDto { export class CreateLiveBlogDto {
@IsString() @IsString()
title: string; title: string;
@@ -292,16 +377,24 @@ export class CreateLiveBlogUpdateDto {
isPinned?: boolean; isPinned?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
authorId?: string; authorId?: string;
@IsOptional() @IsOptional()
@IsDate() @IsDateString()
scheduledAt?: Date; scheduledAt?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; image?: string;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
} }
export class UpdateLiveBlogUpdateDto { export class UpdateLiveBlogUpdateDto {
@@ -314,43 +407,18 @@ export class UpdateLiveBlogUpdateDto {
isPinned?: boolean; isPinned?: boolean;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
authorId?: string; authorId?: string;
@IsOptional() @IsOptional()
@IsDate() @IsString()
scheduledAt?: Date; image?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
strapiId?: string; videoUrl?: string;
}
export class FindLiveBlogsDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
category?: string; authorName?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: 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 { TypeOrmModule } from '@nestjs/typeorm';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller'; import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities'; import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Article, Author, Category])], imports: [
TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule),
],
controllers: [ArticlesController], controllers: [ArticlesController],
providers: [ArticlesService], providers: [ArticlesService],
exports: [ArticlesService], exports: [ArticlesService],
+160 -11
View File
@@ -1,7 +1,14 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import {
Injectable,
NotFoundException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Article, ArticleStatus } from './entities'; import { Article, ArticleStatus } from './entities';
import { StrapiService } from './strapi.service';
import { import {
CreateArticleDto, CreateArticleDto,
UpdateArticleDto, UpdateArticleDto,
@@ -15,6 +22,8 @@ export class ArticlesService {
constructor( constructor(
@InjectRepository(Article) @InjectRepository(Article)
private readonly articleRepository: Repository<Article>, private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {} ) {}
async create(dto: CreateArticleDto): Promise<Article> { async create(dto: CreateArticleDto): Promise<Article> {
@@ -102,44 +111,173 @@ export class ArticlesService {
async update(id: string, dto: UpdateArticleDto): Promise<Article> { async update(id: string, dto: UpdateArticleDto): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
const oldStatus = article.status;
Object.assign(article, dto); Object.assign(article, dto);
return await this.articleRepository.save(article); const savedArticle = await this.articleRepository.save(article);
// Update Strapi if status changed and article has strapiId
if (
dto.status !== undefined &&
dto.status !== oldStatus &&
article.strapiId
) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
dto.status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
} }
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const article = await this.findOne(id); const article = await this.findOne(id);
// Delete from Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.deleteArticleFromStrapi(article.strapiId);
} catch (error) {
// Log error but don't fail - we still want to delete from backend
this.logger.error(`Failed to delete article ${id} from Strapi:`, error);
}
}
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
} }
async archive(id: string): Promise<Article> { async archive(id: string): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
article.status = ArticleStatus.ARCHIVED; article.status = ArticleStatus.ARCHIVED;
return await this.articleRepository.save(article); 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,
);
}
} }
async publish(id: string, status: ArticleStatus = ArticleStatus.PUBLISHED): Promise<Article> { return savedArticle;
}
async publish(
id: string,
status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
article.status = status; article.status = status;
return await this.articleRepository.save(article); const savedArticle = await this.articleRepository.save(article);
// Update Strapi if article has strapiId
if (article.strapiId) {
try {
await this.strapiService.updateArticleStatusInStrapi(
article.strapiId,
status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for article ${id}:`,
error,
);
}
}
return savedArticle;
} }
async syncFromStrapi( async syncFromStrapi(
strapiId: string, strapiId: string,
data: Partial<CreateArticleDto>, data: Partial<CreateArticleDto>,
): Promise<Article> { ): Promise<Article> {
let article = await this.articleRepository.findOne({ where: { strapiId } }); // Use upsert to handle race conditions and ensure uniqueness
try {
// First try to find existing article by strapiId
const article = await this.articleRepository.findOne({
where: { strapiId },
});
if (!article) { if (article) {
article = this.articleRepository.create({ // Update existing article
const currentStatus = article.status;
Object.assign(article, data);
// Preserve archived status if article is already archived in our system
// unless Strapi explicitly sends a different status
if (
currentStatus === ArticleStatus.ARCHIVED &&
data.status !== ArticleStatus.ARCHIVED
) {
article.status = ArticleStatus.ARCHIVED;
}
return await this.articleRepository.save(article);
} else {
// Create new article
const newArticle = this.articleRepository.create({
strapiId, strapiId,
...data, ...data,
status: data.status || ArticleStatus.DRAFT, status: data.status || ArticleStatus.DRAFT,
}); });
} else {
Object.assign(article, data); return await this.articleRepository.save(newArticle);
}
} catch (error: unknown) {
// If we get a unique constraint violation, try to find and update again
// This handles race conditions where two syncs happen simultaneously
const dbError = error as { code?: string; constraint?: string };
if (
dbError.code === '23505' &&
dbError.constraint?.includes('strapiId')
) {
this.logger.warn(
`Race condition detected for strapiId ${strapiId}, retrying...`,
);
// Wait a bit and retry
await new Promise((resolve) => setTimeout(resolve, 100));
const existingArticle = await this.articleRepository.findOne({
where: { strapiId },
});
if (existingArticle) {
const currentStatus = existingArticle.status;
Object.assign(existingArticle, data);
if (
currentStatus === ArticleStatus.ARCHIVED &&
data.status !== ArticleStatus.ARCHIVED
) {
existingArticle.status = ArticleStatus.ARCHIVED;
} }
return await this.articleRepository.save(article); return await this.articleRepository.save(existingArticle);
}
}
// Re-throw other errors
throw error;
}
} }
async removeByStrapiId(strapiId: string): Promise<void> { async removeByStrapiId(strapiId: string): Promise<void> {
@@ -155,4 +293,15 @@ export class ArticlesService {
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`); 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,
};
}
}
+185 -2
View File
@@ -56,6 +56,17 @@ export enum VideoPosition {
NONE = 'none', NONE = 'none',
} }
export enum UserRole {
ADMIN = 'admin',
CONTRIBUTOR = 'contributor',
USER = 'user',
}
export enum ReactionType {
LIKE = 'like',
DISLIKE = 'dislike',
}
@Entity('authors') @Entity('authors')
export class Author { export class Author {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -114,6 +125,36 @@ export class Category {
parent: Category; parent: Category;
} }
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column({ unique: true })
username: string;
@Column()
passwordHash: string;
@Column({
type: 'text',
default: 'user',
})
role: UserRole;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@Entity('articles') @Entity('articles')
export class Article { export class Article {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -174,7 +215,7 @@ export class Article {
@Column({ default: 0 }) @Column({ default: 0 })
views: number; views: number;
@Column({ nullable: true }) @Column({ nullable: true, unique: true })
strapiId: string; strapiId: string;
@Column({ nullable: true }) @Column({ nullable: true })
@@ -183,6 +224,39 @@ export class Article {
@Column({ nullable: true }) @Column({ nullable: true })
categoryId: string; 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() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -196,6 +270,12 @@ export class Article {
@ManyToOne(() => Category, { onDelete: 'SET NULL' }) @ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' }) @JoinColumn({ name: 'categoryId' })
category: Category; category: Category;
@OneToMany(() => Comment, (comment) => comment.article)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.article)
reactions: Reaction[];
} }
@Entity('live_blogs') @Entity('live_blogs')
@@ -221,7 +301,7 @@ export class LiveBlog {
@Column({ default: false }) @Column({ default: false })
isPinned: boolean; isPinned: boolean;
@Column({ nullable: true }) @Column({ nullable: true, unique: true })
strapiId: string; strapiId: string;
@Column({ nullable: true }) @Column({ nullable: true })
@@ -278,6 +358,12 @@ export class LiveBlog {
cascade: true, cascade: true,
}) })
updates: LiveBlogUpdate[]; updates: LiveBlogUpdate[];
@OneToMany(() => Comment, (comment) => comment.liveBlog)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
reactions: Reaction[];
} }
@Entity('live_blog_updates') @Entity('live_blog_updates')
@@ -314,3 +400,100 @@ export class LiveBlogUpdate {
@JoinColumn({ name: 'liveBlogId' }) @JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog; liveBlog: LiveBlog;
} }
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('text')
content: string;
@Column({ nullable: true })
articleId: string;
@Column({ nullable: true })
liveBlogId: string;
@Column({ nullable: true })
parentId: string;
@Column()
userId: string;
@Column({ default: 0 })
likeCount: number;
@Column({ default: 0 })
dislikeCount: number;
@Column({ default: true })
isVisible: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'parentId' })
parent: Comment;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@OneToMany(() => Comment, (comment) => comment.parent)
replies: Comment[];
}
@Entity('reactions')
export class Reaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'text',
})
type: ReactionType;
@Column({ nullable: true })
articleId: string;
@Column({ nullable: true })
liveBlogId: string;
@Column({ nullable: true })
commentId: string;
@Column()
userId: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'articleId' })
article: Article;
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'liveBlogId' })
liveBlog: LiveBlog;
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'commentId' })
comment: Comment;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
}
+36 -18
View File
@@ -8,10 +8,10 @@ import {
Body, Body,
Param, Param,
Query, Query,
ValidationPipe,
Res, Res,
Logger, Logger,
Headers, Headers,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response as ExpressResponse } from 'express'; import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
@@ -22,7 +22,11 @@ import {
CreateLiveBlogUpdateDto, CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto,
} from './articles.dto'; } from './articles.dto';
import { LiveBlogStatus } from './entities'; import { LiveBlogStatus, UserRole } from './entities';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
import { Roles } from './auth/roles.decorator';
import { Public } from './auth/public.decorator';
@Controller('live-blogs') @Controller('live-blogs')
export class LiveBlogController { export class LiveBlogController {
@@ -32,68 +36,76 @@ export class LiveBlogController {
// Live Blog CRUD operations // Live Blog CRUD operations
@Get('featured') @Get('featured')
@Public()
getFeatured() { getFeatured() {
this.logger.log('GET /featured called'); this.logger.log('GET /featured called');
return this.liveBlogService.findPinned(); return this.liveBlogService.findPinned();
} }
@Get('active') @Get('active')
@Public()
getActive() { getActive() {
return this.liveBlogService.findActive(); return this.liveBlogService.findActive();
} }
@Get('recent') @Get('recent')
@Public()
getRecent() { getRecent() {
return this.liveBlogService.getLiveBlogsWithRecentUpdates(); return this.liveBlogService.getLiveBlogsWithRecentUpdates();
} }
@Post() @Post()
create( @UseGuards(JwtAuthGuard, RolesGuard)
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
) { create(@Body() dto: CreateLiveBlogDto) {
return this.liveBlogService.create(dto); return this.liveBlogService.create(dto);
} }
@Get() @Get()
findAll( @Public()
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto, findAll(@Query() dto: FindLiveBlogsDto) {
) {
return this.liveBlogService.findAll(dto); return this.liveBlogService.findAll(dto);
} }
@Get(':id') @Get(':id')
@Public()
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.liveBlogService.findOne(id); return this.liveBlogService.findOne(id);
} }
@Get('slug/:slug') @Get('slug/:slug')
@Public()
findBySlug(@Param('slug') slug: string) { findBySlug(@Param('slug') slug: string) {
return this.liveBlogService.findBySlug(slug); return this.liveBlogService.findBySlug(slug);
} }
@Put(':id') @Put(':id')
update( @UseGuards(JwtAuthGuard, RolesGuard)
@Param('id') id: string, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto, update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
) {
return this.liveBlogService.update(id, dto); return this.liveBlogService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.liveBlogService.remove(id); return this.liveBlogService.remove(id);
} }
// Live Blog Updates CRUD operations // Live Blog Updates CRUD operations
@Post(':id/updates') @Post(':id/updates')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
createUpdate( createUpdate(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto, @Body() dto: CreateLiveBlogUpdateDto,
) { ) {
return this.liveBlogService.createUpdate(dto, liveBlogId); return this.liveBlogService.createUpdate(dto, liveBlogId);
} }
@Get(':id/updates') @Get(':id/updates')
@Public()
findUpdates( findUpdates(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Query('page') page = 1, @Query('page') page = 1,
@@ -107,15 +119,19 @@ export class LiveBlogController {
} }
@Put(':id/updates/:updateId') @Put(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
updateUpdate( updateUpdate(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Param('updateId') updateId: string, @Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto, @Body() dto: UpdateLiveBlogUpdateDto,
) { ) {
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto); return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
} }
@Delete(':id/updates/:updateId') @Delete(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
removeUpdate( removeUpdate(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Param('updateId') updateId: string, @Param('updateId') updateId: string,
@@ -124,20 +140,22 @@ export class LiveBlogController {
} }
@Patch(':id/archive') @Patch(':id/archive')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
archive(@Param('id') id: string) { archive(@Param('id') id: string) {
return this.liveBlogService.archive(id); return this.liveBlogService.archive(id);
} }
@Patch(':id/publish') @Patch(':id/publish')
publish( @UseGuards(JwtAuthGuard, RolesGuard)
@Param('id') id: string, @Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Query('status') status?: LiveBlogStatus, publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
) {
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT); return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
} }
// SSE endpoint for real-time updates // SSE endpoint for real-time updates
@Get(':id/stream') @Get(':id/stream')
@Public()
stream( stream(
@Param('id') liveBlogId: string, @Param('id') liveBlogId: string,
@Res() response: ExpressResponse, @Res() response: ExpressResponse,
+3 -1
View File
@@ -1,14 +1,16 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller'; import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule),
], ],
controllers: [LiveBlogController], controllers: [LiveBlogController],
providers: [LiveBlogService], providers: [LiveBlogService],
+137 -9
View File
@@ -3,12 +3,15 @@ import {
NotFoundException, NotFoundException,
Logger, Logger,
OnModuleInit, OnModuleInit,
Inject,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Response } from 'express'; import { Response } from 'express';
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities'; import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
import { StrapiService } from './strapi.service';
import { import {
CreateLiveBlogDto, CreateLiveBlogDto,
UpdateLiveBlogDto, UpdateLiveBlogDto,
@@ -50,6 +53,8 @@ export class LiveBlogService implements OnModuleInit {
@InjectRepository(LiveBlogUpdate) @InjectRepository(LiveBlogUpdate)
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>, private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -248,6 +253,22 @@ export class LiveBlogService implements OnModuleInit {
blogId: id, blogId: id,
status: dto.status, status: dto.status,
}); });
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
dto.status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
} }
return updatedBlog; return updatedBlog;
@@ -255,19 +276,72 @@ export class LiveBlogService implements OnModuleInit {
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const liveBlog = await this.findOne(id); const liveBlog = await this.findOne(id);
// Delete from Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId);
} catch (error) {
// Log error but don't fail - we still want to delete from backend
this.logger.error(
`Failed to delete live blog ${id} from Strapi:`,
error,
);
}
}
await this.liveBlogRepository.remove(liveBlog); await this.liveBlogRepository.remove(liveBlog);
} }
async archive(id: string): Promise<LiveBlog> { async archive(id: string): Promise<LiveBlog> {
const liveBlog = await this.findOne(id); const liveBlog = await this.findOne(id);
liveBlog.status = LiveBlogStatus.ARCHIVED; liveBlog.status = LiveBlogStatus.ARCHIVED;
return await this.liveBlogRepository.save(liveBlog); 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,
);
}
} }
async publish(id: string, status: LiveBlogStatus = LiveBlogStatus.DRAFT): Promise<LiveBlog> { return savedLiveBlog;
}
async publish(
id: string,
status: LiveBlogStatus = LiveBlogStatus.DRAFT,
): Promise<LiveBlog> {
const liveBlog = await this.findOne(id); const liveBlog = await this.findOne(id);
liveBlog.status = status; liveBlog.status = status;
return await this.liveBlogRepository.save(liveBlog); const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
// Update Strapi if live blog has strapiId
if (liveBlog.strapiId) {
try {
await this.strapiService.updateLiveBlogStatusInStrapi(
liveBlog.strapiId,
status,
);
} catch (error) {
// Log error but don't fail - backend status is updated
this.logger.error(
`Failed to update Strapi status for live blog ${id}:`,
error,
);
}
}
return savedLiveBlog;
} }
// Live Blog Update CRUD operations // Live Blog Update CRUD operations
@@ -433,21 +507,75 @@ export class LiveBlogService implements OnModuleInit {
strapiId: string, strapiId: string,
data: Partial<CreateLiveBlogDto>, data: Partial<CreateLiveBlogDto>,
): Promise<LiveBlog> { ): Promise<LiveBlog> {
let liveBlog = await this.liveBlogRepository.findOne({ // Use upsert to handle race conditions and ensure uniqueness
try {
// First try to find existing live blog by strapiId
const liveBlog = await this.liveBlogRepository.findOne({
where: { strapiId }, where: { strapiId },
}); });
if (!liveBlog) { if (liveBlog) {
liveBlog = this.liveBlogRepository.create({ // Update existing live blog
const currentStatus = liveBlog.status;
Object.assign(liveBlog, data);
// Preserve archived status if live blog is already archived in our system
// unless Strapi explicitly sends a different status
if (
currentStatus === LiveBlogStatus.ARCHIVED &&
data.status !== LiveBlogStatus.ARCHIVED
) {
liveBlog.status = LiveBlogStatus.ARCHIVED;
}
return await this.liveBlogRepository.save(liveBlog);
} else {
// Create new live blog
const newLiveBlog = this.liveBlogRepository.create({
strapiId, strapiId,
...data, ...data,
status: data.status || LiveBlogStatus.DRAFT, status: data.status || LiveBlogStatus.DRAFT,
}); });
} else {
Object.assign(liveBlog, data); return await this.liveBlogRepository.save(newLiveBlog);
}
} catch (error: unknown) {
// If we get a unique constraint violation, try to find and update again
// This handles race conditions where two syncs happen simultaneously
const dbError = error as { code?: string; constraint?: string };
if (
dbError.code === '23505' &&
dbError.constraint?.includes('strapiId')
) {
this.logger.warn(
`Race condition detected for strapiId ${strapiId}, retrying...`,
);
// Wait a bit and retry
await new Promise((resolve) => setTimeout(resolve, 100));
const existingLiveBlog = await this.liveBlogRepository.findOne({
where: { strapiId },
});
if (existingLiveBlog) {
const currentStatus = existingLiveBlog.status;
Object.assign(existingLiveBlog, data);
if (
currentStatus === LiveBlogStatus.ARCHIVED &&
data.status !== LiveBlogStatus.ARCHIVED
) {
existingLiveBlog.status = LiveBlogStatus.ARCHIVED;
} }
return await this.liveBlogRepository.save(liveBlog); return await this.liveBlogRepository.save(existingLiveBlog);
}
}
// Re-throw other errors
throw error;
}
} }
async removeByStrapiId(strapiId: string): Promise<void> { async removeByStrapiId(strapiId: string): Promise<void> {
+4
View File
@@ -1,5 +1,6 @@
import { Controller, Post, Body, Logger } from '@nestjs/common'; import { Controller, Post, Body, Logger } from '@nestjs/common';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { Public } from './auth/public.decorator';
interface WebhookBody { interface WebhookBody {
event: event:
@@ -21,6 +22,7 @@ export class StrapiController {
constructor(private readonly strapiService: StrapiService) {} constructor(private readonly strapiService: StrapiService) {}
@Post('article') @Post('article')
@Public()
async handleArticleWebhook(@Body() body: WebhookBody) { async handleArticleWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`); this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body; const { event, model, entry } = body;
@@ -38,6 +40,7 @@ export class StrapiController {
} }
@Post('live-blog') @Post('live-blog')
@Public()
async handleLiveBlogWebhook(@Body() body: WebhookBody) { async handleLiveBlogWebhook(@Body() body: WebhookBody) {
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`); this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
const { event, model, entry } = body; const { event, model, entry } = body;
@@ -55,6 +58,7 @@ export class StrapiController {
} }
@Post() @Post()
@Public()
async handleGenericWebhook(@Body() body: unknown) { async handleGenericWebhook(@Body() body: unknown) {
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`); this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
+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 { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller'; import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module'; import { ArticlesModule } from './articles.module';
import { LiveBlogModule } from './live-blog.module'; import { LiveBlogModule } from './live-blog.module';
import { Category } from './entities';
@Module({ @Module({
imports: [HttpModule, ArticlesModule, LiveBlogModule], imports: [
HttpModule,
TypeOrmModule.forFeature([Category]),
forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule),
],
controllers: [StrapiController], controllers: [StrapiController],
providers: [StrapiService], providers: [StrapiService],
exports: [StrapiService], exports: [StrapiService],
+284 -14
View File
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
@@ -11,8 +13,24 @@ import {
ImagePosition, ImagePosition,
ImageSize, ImageSize,
VideoPosition, VideoPosition,
Category,
} from './entities'; } from './entities';
interface StrapiImage {
url: string;
alternativeText?: string;
caption?: string;
width?: number;
height?: number;
formats?: Record<string, StrapiImageFormat>;
}
interface StrapiImageFormat {
url: string;
width: number;
height: number;
}
interface StrapiArticle { interface StrapiArticle {
id: number; id: number;
documentId: string; documentId: string;
@@ -23,13 +41,14 @@ interface StrapiArticle {
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
img?: any; img?: StrapiImage;
media?: any[]; media?: StrapiImage[];
imagePosition?: string; imagePosition?: string;
imageSize?: string; imageSize?: string;
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@@ -42,13 +61,14 @@ interface StrapiLiveBlog {
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
img?: any; img?: StrapiImage;
media?: any[]; media?: StrapiImage[];
imagePosition?: string; imagePosition?: string;
imageSize?: string; imageSize?: string;
videoUrl?: string; videoUrl?: string;
videoPosition?: string; videoPosition?: string;
videoCaption?: string; videoCaption?: string;
category?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@@ -72,8 +92,12 @@ export class StrapiService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
@Inject(forwardRef(() => ArticlesService))
private readonly articlesService: ArticlesService, private readonly articlesService: ArticlesService,
@Inject(forwardRef(() => LiveBlogService))
private readonly liveBlogService: LiveBlogService, private readonly liveBlogService: LiveBlogService,
@InjectRepository(Category)
private readonly categoryRepository: Repository<Category>,
) { ) {
this.strapiUrl = this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337'; this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
@@ -82,13 +106,51 @@ export class StrapiService {
} }
private getHeaders() { private getHeaders() {
const headers: Record<string, string> = { return {
'Content-Type': 'application/json', Authorization: `Bearer ${this.strapiApiToken}`,
}; };
if (this.strapiApiToken) {
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
} }
return headers;
private async findOrCreateCategory(
categorySlug: string,
): Promise<Category | null> {
if (!categorySlug) {
return null;
}
// 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 { private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
@@ -164,6 +226,17 @@ export class StrapiService {
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle); 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> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -181,6 +254,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -239,6 +313,17 @@ export class StrapiService {
const imageUrl = this.extractImageUrl(strapiArticle); 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> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -253,6 +338,7 @@ export class StrapiService {
videoPosition: (strapiArticle.videoPosition || videoPosition: (strapiArticle.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '', videoCaption: strapiArticle.videoCaption || '',
categoryId,
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@@ -262,9 +348,10 @@ export class StrapiService {
this.logger.log( this.logger.log(
`Successfully synced article: ${strapiArticle.title} with status: ${status}`, `Successfully synced article: ${strapiArticle.title} with status: ${status}`,
); );
} catch (error: any) { } catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft // If we get a 404 and it's an unpublish event, we can still mark it as draft
if (event === 'entry.unpublish' && error.response?.status === 404) { const axiosError = error as { response?: { status: number } };
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
this.logger.log( this.logger.log(
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`, `Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
); );
@@ -315,6 +402,17 @@ export class StrapiService {
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); 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> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -328,6 +426,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -382,6 +481,17 @@ export class StrapiService {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog); 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> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
@@ -394,6 +504,7 @@ export class StrapiService {
videoPosition: (strapiLiveBlog.videoPosition || videoPosition: (strapiLiveBlog.videoPosition ||
'inline') as VideoPosition, 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '', videoCaption: strapiLiveBlog.videoCaption || '',
categoryId,
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@@ -403,9 +514,10 @@ export class StrapiService {
this.logger.log( this.logger.log(
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`, `Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
); );
} catch (error: any) { } catch (error: unknown) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft // If we get a 404 and it's an unpublish event, we can still mark it as draft
if (event === 'entry.unpublish' && error.response?.status === 404) { const axiosError = error as { response?: { status: number } };
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
this.logger.log( this.logger.log(
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`, `Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
); );
@@ -489,4 +601,162 @@ export class StrapiService {
this.logger.warn(`Unknown model type in webhook: ${data.model}`); this.logger.warn(`Unknown model type in webhook: ${data.model}`);
} }
} }
async updateArticleStatusInStrapi(
strapiId: string,
status: ArticleStatus,
): Promise<void> {
try {
this.logger.log(
`Updating article ${strapiId} status in Strapi to: ${status}`,
);
// Map our status to Strapi status
let strapiStatus: string;
switch (status) {
case ArticleStatus.DRAFT:
strapiStatus = 'draft';
break;
case ArticleStatus.PUBLISHED:
strapiStatus = 'published';
break;
case ArticleStatus.ARCHIVED:
strapiStatus = 'archived';
break;
default:
strapiStatus = 'draft';
}
// For archived status, we need to unpublish first if it's published
if (status === ArticleStatus.ARCHIVED) {
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/articles/${strapiId}/unpublish`,
{},
{ headers: this.getHeaders() },
),
);
}
// Update the status
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/articles/${strapiId}`,
{ data: { status: strapiStatus } },
{ headers: this.getHeaders() },
),
);
this.logger.log(
`Successfully updated article ${strapiId} status in Strapi to: ${status}`,
);
} catch (error) {
this.logger.error(
`Failed to update article ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async updateLiveBlogStatusInStrapi(
strapiId: string,
status: LiveBlogStatus,
): Promise<void> {
try {
this.logger.log(
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
);
// Map our status to Strapi status
let strapiStatus: string;
switch (status) {
case LiveBlogStatus.DRAFT:
strapiStatus = 'draft';
break;
case LiveBlogStatus.LIVE:
strapiStatus = 'live';
break;
case LiveBlogStatus.ENDED:
strapiStatus = 'ended';
break;
case LiveBlogStatus.ARCHIVED:
strapiStatus = 'archived';
break;
default:
strapiStatus = 'draft';
}
// For archived status, we need to unpublish first if it's published
if (status === LiveBlogStatus.ARCHIVED) {
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`,
{},
{ headers: this.getHeaders() },
),
);
}
// Update the status
await lastValueFrom(
this.httpService.patch(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{ data: { status: strapiStatus } },
{ headers: this.getHeaders() },
),
);
this.logger.log(
`Successfully updated live blog ${strapiId} status in Strapi to: ${status}`,
);
} catch (error) {
this.logger.error(
`Failed to update live blog ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async deleteArticleFromStrapi(strapiId: string): Promise<void> {
try {
this.logger.log(`Deleting article ${strapiId} from Strapi`);
await lastValueFrom(
this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, {
headers: this.getHeaders(),
}),
);
this.logger.log(`Successfully deleted article ${strapiId} from Strapi`);
} catch (error) {
this.logger.error(
`Failed to delete article ${strapiId} from Strapi:`,
error,
);
throw error;
}
}
async deleteLiveBlogFromStrapi(strapiId: string): Promise<void> {
try {
this.logger.log(`Deleting live blog ${strapiId} from Strapi`);
await lastValueFrom(
this.httpService.delete(
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
{ headers: this.getHeaders() },
),
);
this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`);
} catch (error) {
this.logger.error(
`Failed to delete live blog ${strapiId} from Strapi:`,
error,
);
throw error;
}
}
} }
@@ -0,0 +1,104 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UserService } from './user.service';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
ChangePasswordDto,
} from './user.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserRole } from '../entities';
import type { RequestWithUser } from '../auth/types';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.userService.create(createUserDto);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async findAll(): Promise<UserResponseDto[]> {
return this.userService.findAll();
}
@Get('profile')
@UseGuards(JwtAuthGuard)
async getProfile(@Request() req: RequestWithUser): Promise<UserResponseDto> {
return this.userService.findOne(req.user.id);
}
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.findOne(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return this.userService.update(id, updateUserDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string): Promise<void> {
return this.userService.remove(id);
}
@Post(':id/change-password')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async changePassword(
@Param('id') id: string,
@Body() changePasswordDto: ChangePasswordDto,
@Request() req: RequestWithUser,
): Promise<void> {
// Users can only change their own password, unless they're admin
if (req.user.role !== UserRole.ADMIN && req.user.id !== id) {
throw new Error('You can only change your own password');
}
return this.userService.changePassword(id, changePasswordDto);
}
@Patch(':id/deactivate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async deactivate(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.update(id, { isActive: false });
}
@Patch(':id/activate')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async activate(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.update(id, { isActive: true });
}
}
+111
View File
@@ -0,0 +1,111 @@
import {
IsEmail,
IsString,
IsEnum,
IsBoolean,
IsOptional,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
import { UserRole } from '../entities';
interface UserEntity {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
username: string;
@IsString()
@MinLength(6)
password: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole = UserRole.USER;
@IsBoolean()
@IsOptional()
isActive?: boolean = true;
}
export class UpdateUserDto {
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@MinLength(3)
@MaxLength(30)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
@IsOptional()
username?: string;
@IsString()
@MinLength(6)
@IsOptional()
password?: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class LoginUserDto {
@IsString()
username: string;
@IsString()
password: string;
}
export class UserResponseDto {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
constructor(user: UserEntity) {
this.id = user.id;
this.email = user.email;
this.username = user.username;
this.role = user.role;
this.isActive = user.isActive;
this.createdAt = user.createdAt;
this.updatedAt = user.updatedAt;
}
}
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(6)
newPassword: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+212
View File
@@ -0,0 +1,212 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User, UserRole } from '../entities';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
ChangePasswordDto,
} from './user.dto';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
// Check if email already exists
const existingEmail = await this.userRepository.findOne({
where: { email: createUserDto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
// Check if username already exists
const existingUsername = await this.userRepository.findOne({
where: { username: createUserDto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
// Create user
const user = this.userRepository.create({
...createUserDto,
passwordHash,
});
const savedUser = await this.userRepository.save(user);
return new UserResponseDto(savedUser);
}
async findAll(): Promise<UserResponseDto[]> {
const users = await this.userRepository.find({
order: { createdAt: 'DESC' },
});
return users.map((user) => new UserResponseDto(user));
}
async findOne(id: string): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
return new UserResponseDto(user);
}
async findByUsername(username: string): Promise<User | null> {
return this.userRepository.findOne({ where: { username } });
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async update(
id: string,
updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Check if email is being changed and if it already exists
if (updateUserDto.email && updateUserDto.email !== user.email) {
const existingEmail = await this.userRepository.findOne({
where: { email: updateUserDto.email },
});
if (existingEmail) {
throw new ConflictException('Email already exists');
}
}
// Check if username is being changed and if it already exists
if (updateUserDto.username && updateUserDto.username !== user.username) {
const existingUsername = await this.userRepository.findOne({
where: { username: updateUserDto.username },
});
if (existingUsername) {
throw new ConflictException('Username already exists');
}
}
// Hash new password if provided
if (updateUserDto.password) {
const saltRounds = 10;
updateUserDto.password = await bcrypt.hash(
updateUserDto.password,
saltRounds,
);
}
// Update user
Object.assign(user, updateUserDto);
if (updateUserDto.password) {
user.passwordHash = updateUserDto.password;
delete updateUserDto.password;
}
const updatedUser = await this.userRepository.save(user);
return new UserResponseDto(updatedUser);
}
async remove(id: string): Promise<void> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Prevent deleting the last admin
if (user.role === UserRole.ADMIN) {
const adminCount = await this.userRepository.count({
where: { role: UserRole.ADMIN },
});
if (adminCount <= 1) {
throw new BadRequestException('Cannot delete the last admin user');
}
}
await this.userRepository.remove(user);
}
async changePassword(
id: string,
changePasswordDto: ChangePasswordDto,
): Promise<void> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
// Verify current password
const isPasswordValid = await bcrypt.compare(
changePasswordDto.currentPassword,
user.passwordHash,
);
if (!isPasswordValid) {
throw new BadRequestException('Current password is incorrect');
}
// Hash new password
const saltRounds = 10;
user.passwordHash = await bcrypt.hash(
changePasswordDto.newPassword,
saltRounds,
);
await this.userRepository.save(user);
}
async validateUser(username: string, password: string): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { username, isActive: true },
});
if (!user) {
return null;
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return null;
}
return user;
}
async createAdminIfNotExists(): Promise<void> {
const adminExists = await this.userRepository.findOne({
where: { role: UserRole.ADMIN },
});
if (!adminExists) {
const saltRounds = 10;
const passwordHash = await bcrypt.hash('admin123', saltRounds);
const adminUser = this.userRepository.create({
email: 'admin@placebo.mk',
username: 'admin',
passwordHash,
role: UserRole.ADMIN,
isActive: true,
});
await this.userRepository.save(adminUser);
console.log('Default admin user created: admin / admin123');
}
}
}
@@ -63,6 +63,12 @@
"videoCaption": { "videoCaption": {
"type": "string", "type": "string",
"default": "" "default": ""
},
"category": {
"type": "enumeration",
"enum": ["sport", "art", "science"],
"default": "sport",
"required": true
} }
} }
} }
+3 -3
View File
@@ -24,9 +24,9 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: placebo-backend-dev container_name: placebo-backend-dev
env_file: env_file:
- ./backend/.env # Use the .env file for configuration - ./backend/.env.docker # Use the .env.docker file for Docker configuration
environment: environment:
# Only override if needed, most config is in .env file # Only override if needed, most config is in .env.docker file
NODE_ENV: development NODE_ENV: development
ports: ports:
- "3000:3000" - "3000:3000"
@@ -36,7 +36,7 @@ services:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/package.json:/app/package.json - ./backend/package.json:/app/package.json
- ./backend/package-lock.json:/app/package-lock.json - ./backend/package-lock.json:/app/package-lock.json
- ./backend/.env:/app/.env # Mount .env file into container - ./backend/.env.docker:/app/.env # Mount .env.docker file as .env in container
command: npm run dev:docker command: npm run dev:docker
networks: networks:
- placebo-network-dev - placebo-network-dev
+3 -5
View File
@@ -6,7 +6,7 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
container_name: placebo-postgres container_name: placebo-postgres
environment: environment:
POSTGRES_DB: placebo_db POSTGRES_DB: placebo_backend_db
POSTGRES_USER: placebo_user POSTGRES_USER: placebo_user
POSTGRES_PASSWORD: placebo_password POSTGRES_PASSWORD: placebo_password
volumes: volumes:
@@ -15,7 +15,7 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_db"] test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_backend_db"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -35,11 +35,9 @@ services:
DATABASE_PORT: 5432 DATABASE_PORT: 5432
DATABASE_USERNAME: placebo_user DATABASE_USERNAME: placebo_user
DATABASE_PASSWORD: placebo_password DATABASE_PASSWORD: placebo_password
DATABASE_NAME: placebo_db DATABASE_NAME: placebo_backend_db
DATABASE_SYNCHRONIZE: "false" DATABASE_SYNCHRONIZE: "false"
DATABASE_LOGGING: "false" DATABASE_LOGGING: "false"
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
JWT_EXPIRATION: 3600
CORS_ORIGIN: http://localhost:5173,http://localhost:3001 CORS_ORIGIN: http://localhost:5173,http://localhost:3001
PORT: 3000 PORT: 3000
ports: ports:
+12 -13
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import { Zap } from 'lucide-react'
export function ArticleTicker() { export function ArticleTicker() {
const { data } = useQuery({ const { data } = useQuery({
@@ -13,29 +14,28 @@ export function ArticleTicker() {
if (articles.length === 0) return null if (articles.length === 0) return null
return ( return (
<div className="overflow-hidden bg-muted/50 border-y"> <div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
<div className="container mx-auto max-w-6xl px-4"> <div className="py-2 flex items-center">
<div className="py-2 flex items-center gap-4"> <div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
<span className="text-sm font-semibold text-primary whitespace-nowrap"> <Zap className="w-4 h-4" />
Latest: Топ вести
</span> </div>
<div className="overflow-hidden flex-1 relative"> <div className="overflow-hidden flex-1">
<div className="flex animate-marquee whitespace-nowrap"> <div className="flex animate-marquee whitespace-nowrap">
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`${article.id}-${index}`} key={`${article.id}-${index}`}
to={`/articles/${article.id}` as any} to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
> >
{article.title || 'No title'} {article.title || 'No title'}
</Link> </Link>
))} ))}
{/* Duplicate for seamless scrolling */}
{articles.map((article, index) => ( {articles.map((article, index) => (
<Link <Link
key={`dup-${article.id}-${index}`} key={`dup-${article.id}-${index}`}
to={`/articles/${article.id}` as any} to={`/articles/${article.id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4" className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
> >
{article.title || 'No title'} {article.title || 'No title'}
</Link> </Link>
@@ -44,6 +44,5 @@ export function ArticleTicker() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }
@@ -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>
);
}
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useActiveLiveBlogs } from '@/queries/live-blogs'; import { useActiveLiveBlogs } from '@/queries/live-blogs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Play, Pause, ChevronRight, Radio } from 'lucide-react'; import { Play, Pause, ChevronRight, Radio } from 'lucide-react';
@@ -9,7 +8,7 @@ interface LiveBlogTickerProps {
className?: string; className?: string;
maxItems?: number; maxItems?: number;
autoScroll?: boolean; autoScroll?: boolean;
scrollSpeed?: number; // pixels per second scrollSpeed?: number;
} }
export function LiveBlogTicker({ export function LiveBlogTicker({
@@ -25,7 +24,6 @@ export function LiveBlogTicker({
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number | undefined>(undefined); const animationRef = useRef<number | undefined>(undefined);
// Calculate total width needed for scrolling
const [totalWidth, setTotalWidth] = useState(0); const [totalWidth, setTotalWidth] = useState(0);
useEffect(() => { useEffect(() => {
@@ -35,7 +33,6 @@ export function LiveBlogTicker({
} }
}, [activeBlogs]); }, [activeBlogs]);
// Auto-scroll animation
useEffect(() => { useEffect(() => {
if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) { if (!autoScroll || isPaused || !activeBlogs || activeBlogs.length === 0) {
if (animationRef.current) { if (animationRef.current) {
@@ -53,7 +50,6 @@ export function LiveBlogTicker({
setScrollPosition(prev => { setScrollPosition(prev => {
let newPos = prev + (scrollSpeed * delta) / 1000; let newPos = prev + (scrollSpeed * delta) / 1000;
// Reset when scrolled past content
if (newPos > totalWidth) { if (newPos > totalWidth) {
newPos = -(tickerRef.current?.offsetWidth || 0); newPos = -(tickerRef.current?.offsetWidth || 0);
} }
@@ -75,7 +71,7 @@ export function LiveBlogTicker({
if (isLoading) { if (isLoading) {
return ( return (
<div className={`bg-muted/50 border rounded-lg p-3 ${className}`}> <div className={`border-brutal-sm bg-card p-3 ${className}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="animate-pulse h-4 w-24 bg-muted rounded"></div> <div className="animate-pulse h-4 w-24 bg-muted rounded"></div>
@@ -87,7 +83,7 @@ export function LiveBlogTicker({
} }
if (!activeBlogs || activeBlogs.length === 0) { if (!activeBlogs || activeBlogs.length === 0) {
return null; // Don't show ticker if no active blogs return null;
} }
const displayBlogs = activeBlogs.slice(0, maxItems); const displayBlogs = activeBlogs.slice(0, maxItems);
@@ -97,56 +93,48 @@ export function LiveBlogTicker({
}; };
const handleBlogClick = () => { const handleBlogClick = () => {
// Reset scroll position when user interacts
setScrollPosition(0); setScrollPosition(0);
}; };
return ( return (
<div className={`bg-background border rounded-lg overflow-hidden ${className}`}> <div className={`border-brutal-sm bg-card overflow-hidden ${className}`}>
{/* Ticker header */} <div className="flex items-center justify-between px-4 py-2 border-b-2 border-foreground/10 bg-secondary">
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <Radio className="w-4 h-4 text-accent animate-pulse" />
<Radio className="w-4 h-4 text-primary" /> <span className="font-body text-sm font-bold uppercase tracking-wider">LIVE</span>
<span className="font-medium text-sm">Live Now</span> <span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
<Badge variant="secondary" className="text-xs"> {activeBlogs.length}
{activeBlogs.length} active </span>
</Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{autoScroll && ( {autoScroll && (
<Button <Button
variant="ghost" variant="brutal"
size="sm" size="sm"
onClick={handlePauseToggle} onClick={handlePauseToggle}
className="h-7 px-2" className="h-7 px-2 text-xs"
> >
{isPaused ? ( {isPaused ? (
<Play className="w-3 h-3" /> <Play className="w-3 h-3" />
) : ( ) : (
<Pause className="w-3 h-3" /> <Pause className="w-3 h-3" />
)} )}
<span className="ml-1 text-xs">{isPaused ? 'Play' : 'Pause'}</span>
</Button> </Button>
)} )}
<Link to="/live-blogs"> <Link to="/live-blogs">
<Button variant="ghost" size="sm" className="h-7 px-2"> <Button variant="brutal" size="sm" className="h-7 px-2 text-xs">
<span className="text-xs">View All</span> Сите
<ChevronRight className="w-3 h-3 ml-1" /> <ChevronRight className="w-3 h-3 ml-1" />
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Ticker content */}
<div <div
ref={tickerRef} ref={tickerRef}
className="relative h-12 overflow-hidden" className="relative h-12 overflow-hidden"
style={{
maskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent)'
}}
> >
<div <div
ref={contentRef} ref={contentRef}
@@ -156,76 +144,63 @@ export function LiveBlogTicker({
transition: isPaused ? 'transform 0.3s ease' : 'none' transition: isPaused ? 'transform 0.3s ease' : 'none'
}} }}
> >
{displayBlogs.map((blog, index) => ( {displayBlogs.map((blog) => (
<React.Fragment key={blog.id}> <React.Fragment key={blog.id}>
<Link <Link
to="/live-blogs/$slug" to="/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
onClick={handleBlogClick} onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group" className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div> </div>
<span className="font-medium text-sm group-hover:text-primary transition-colors"> <span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title} {blog.title}
</span> </span>
{blog.updates && blog.updates.length > 0 && ( {blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs"> <span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length} updates {blog.updates.length}
</Badge> </span>
)} )}
</div> </div>
</Link> </Link>
{/* Separator (except after last item) */}
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
{/* Duplicate content for seamless looping */} {displayBlogs.map((blog) => (
{displayBlogs.map((blog, index) => (
<React.Fragment key={`${blog.id}-dup`}> <React.Fragment key={`${blog.id}-dup`}>
<Link <Link
to="/live-blogs/$slug" to="/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
onClick={handleBlogClick} onClick={handleBlogClick}
className="inline-flex items-center gap-2 px-4 py-1 rounded-full hover:bg-accent transition-colors group" className="inline-flex items-center gap-2 px-4 py-1 border-r-2 border-foreground/10 hover:bg-accent transition-colors group"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-accent rounded-full animate-pulse"></div>
<div className="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div> </div>
<span className="font-medium text-sm group-hover:text-primary transition-colors"> <span className="font-body text-sm font-medium group-hover:text-foreground transition-colors">
{blog.title} {blog.title}
</span> </span>
{blog.updates && blog.updates.length > 0 && ( {blog.updates && blog.updates.length > 0 && (
<Badge variant="outline" className="text-xs"> <span className="px-2 py-0.5 border border-foreground bg-background text-xs font-body">
{blog.updates.length} updates {blog.updates.length}
</Badge> </span>
)} )}
</div> </div>
</Link> </Link>
{index < displayBlogs.length - 1 && (
<div className="mx-2 text-muted-foreground">•</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
</div> </div>
{/* Progress indicator */}
{autoScroll && totalWidth > 0 && ( {autoScroll && totalWidth > 0 && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<div className="h-1 bg-muted rounded-full overflow-hidden"> <div className="h-1 bg-foreground/10">
<div <div
className="h-full bg-primary transition-all duration-300" className="h-full bg-accent transition-all duration-300"
style={{ style={{
width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%` width: `${Math.min(100, (scrollPosition / totalWidth) * 100 * 2)}%`
}} }}
@@ -214,7 +214,7 @@ export function LiveBlogViewer({ slug, className }: LiveBlogViewerProps) {
<div <div
ref={updatesContainerRef} ref={updatesContainerRef}
className="max-h-[600px] overflow-y-auto px-6 pb-6" className="px-6 pb-6"
onScroll={handleScroll} onScroll={handleScroll}
> >
{updates.length === 0 && !updatesLoading ? ( {updates.length === 0 && !updatesLoading ? (
@@ -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,141 @@
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, Eye } from 'lucide-react';
export function HeroArticle() {
const { data: article, isLoading, error } = useQuery({
queryKey: ['hero-article'],
queryFn: fetchHeroArticle,
});
if (isLoading) {
return (
<div className="border-brutal bg-card p-0 animate-pulse">
<div className="h-80 bg-muted"></div>
<div className="p-8">
<div className="h-10 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-20 bg-muted rounded mb-6"></div>
<div className="h-12 bg-muted rounded w-40"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="text-destructive text-xl font-display mb-4">ERROR</div>
<Button variant="brutal" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
);
}
if (!article) {
return (
<div className="border-brutal bg-card p-12 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 border-4 border-foreground mb-6">
<span className="font-display text-4xl">?</span>
</div>
<h2 className="text-3xl font-display mb-4">NO HERO ARTICLE</h2>
<p className="font-body text-muted-foreground mb-4">
Mark an article as "Hero" in the admin panel to feature it here.
</p>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground mt-8 border-t-2 border-foreground/20 pt-4">
This space will showcase your most important story
</div>
</div>
);
}
return (
<article className="group border-brutal bg-card hover:shadow-brutal transition-all duration-200 animate-fade-in-up">
{article.featuredImage && (
<div className="relative overflow-hidden">
<div className="absolute top-0 left-0 z-10">
<span className="inline-block px-4 py-2 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider border-b-2 border-r-2 border-foreground">
Прекршени Вести
</span>
</div>
<div className="relative h-72 md:h-96 overflow-hidden">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-foreground/80 via-foreground/20 to-transparent"></div>
</div>
</div>
)}
<div className="p-6 md:p-8">
<h2 className="text-3xl md:text-4xl font-display leading-tight mb-4 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h2>
<div className="flex flex-wrap items-center gap-4 font-body text-sm uppercase tracking-wider text-muted-foreground mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
</div>
{article.author && (
<div className="flex items-center gap-2">
<User className="w-4 h-4" />
<span>{article.author.name}</span>
</div>
)}
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
<span>{article.views} views</span>
</div>
</div>
{article.excerpt && (
<p className="text-muted-foreground mb-6 line-clamp-3 font-body">
{article.excerpt}
</p>
)}
{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 font-body uppercase tracking-wider border-2 border-foreground bg-background"
>
#{tag}
</span>
))}
</div>
)}
<div className="flex items-center justify-between pt-4 border-t-2 border-foreground/10">
<Link to={`/articles/${article.id}`}>
<Button variant="brutalAccent" className="gap-2">
Read Full Story
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<div className="font-body text-xs uppercase tracking-wider text-muted-foreground">
<span className="font-bold text-foreground">
{(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}
</span> shares
</div>
</div>
</div>
</article>
);
}
@@ -0,0 +1,136 @@
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 { ArrowRight } from 'lucide-react'
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-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-brutal-sm bg-card p-4 animate-pulse">
<div className="h-40 bg-muted mb-4"></div>
<div className="h-6 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 w-1/2"></div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="border-brutal bg-destructive/10 p-8 text-center">
<div className="text-destructive text-2xl font-display mb-2">ГРЕШКА</div>
<p className="font-body text-sm text-destructive">Обидете се повторно</p>
</div>
)
}
const articles = data?.data || []
if (articles.length === 0) {
return (
<div className="border-brutal bg-card p-8 text-center">
<div className="font-display text-2xl mb-2">НЕМА СТАТИИ</div>
<p className="font-body text-sm text-muted-foreground">Проверете подоцна</p>
</div>
)
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between border-b-4 border-foreground pb-4">
<h2 className="text-3xl md:text-4xl font-display">Најнови</h2>
<Link
to="/archive"
className="font-body text-sm uppercase tracking-wider border-2 border-foreground px-4 py-2 hover:bg-accent hover:border-accent transition-all duration-150 flex items-center gap-2"
>
Сите
<ArrowRight className="w-4 h-4" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{articles.map((article, index) => (
<article
key={article.id}
className={`group border-brutal-sm bg-card hover:shadow-brutal transition-all duration-150 hover:-translate-y-1 animate-fade-in-up stagger-${Math.min(index + 1, 12)}`}
>
<Link
to={`/articles/${article.id}`}
className="block"
>
{article.featuredImage ? (
<div className="relative h-40 overflow-hidden border-b-2 border-foreground">
<img
src={article.featuredImage}
alt={article.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors duration-300" />
</div>
) : (
<div className="h-40 bg-secondary border-b-2 border-foreground flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'repeating-linear-gradient(45deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%)', backgroundSize: '10px 10px' }}></div>
<span className="font-display text-4xl text-foreground/30">N</span>
</div>
)}
<div className="p-4">
<h3 className="text-lg font-display leading-tight mb-2 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h3>
{article.excerpt && (
<p className="text-muted-foreground text-xs font-body line-clamp-2 mb-3">
{article.excerpt}
</p>
)}
</div>
</Link>
<div className="px-4 pb-4 border-t-2 border-foreground/10 pt-3 mt-auto">
<div className="flex items-center justify-between font-body text-xs uppercase tracking-wider text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
})}
</span>
{article.category && (
<Link
to={`/${article.category.slug}`}
className="px-2 py-0.5 border border-foreground bg-background text-foreground text-[10px] hover:bg-accent hover:border-accent transition-colors"
>
{article.category.name}
</Link>
)}
</div>
<div className="mt-3">
<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>
</article>
))}
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { fetchPinnedLiveBlogs } from '@/lib/api';
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 (
<span className="px-2 py-0.5 bg-accent text-foreground text-xs font-body font-bold uppercase">
LIVE
</span>
);
case 'ended':
return (
<span className="px-2 py-0.5 border-2 border-foreground/40 text-foreground/40 text-xs font-body font-bold uppercase">
ENDED
</span>
);
case 'archived':
return (
<span className="px-2 py-0.5 border-2 border-foreground/30 text-foreground/30 text-xs font-body font-bold uppercase">
ARCHIVED
</span>
);
default:
return (
<span className="px-2 py-0.5 border-2 border-foreground text-foreground text-xs font-body font-bold uppercase">
DRAFT
</span>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
});
};
if (isLoading) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</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="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="text-center py-4">
<div className="text-destructive font-body text-sm mb-2">ERROR</div>
<Button variant="brutal" size="sm" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}
if (!liveBlogs || liveBlogs.length === 0) {
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center gap-2 mb-6">
<Pin className="h-5 w-5" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<div className="text-center py-6 border-2 border-dashed border-foreground/20 p-4">
<div className="font-body text-muted-foreground mb-2">No pinned live blogs</div>
<p className="font-body text-xs text-muted-foreground">
Pin live blogs from the admin panel.
</p>
</div>
</div>
);
}
return (
<div className="border-brutal-sm bg-card p-6">
<div className="flex items-center justify-between mb-6 pb-4 border-b-2 border-foreground/10">
<div className="flex items-center gap-2">
<Pin className="h-5 w-5 text-accent" />
<h3 className="text-xl font-display">Pinned Live</h3>
</div>
<span className="px-2 py-1 border-2 border-foreground bg-foreground text-background text-xs font-body font-bold uppercase">
{liveBlogs.length}
</span>
</div>
<div className="space-y-4">
{liveBlogs.map((liveBlog) => (
<Link
key={liveBlog.id}
to="/live-blogs/$slug"
params={{ slug: liveBlog.slug }}
className="block group"
>
<div className="p-4 border-2 border-foreground/10 hover:border-foreground hover:shadow-brutal-sm transition-all duration-150 group-hover:-translate-y-1">
<div className="flex items-start justify-between mb-2">
<h4 className="font-body text-sm font-bold leading-tight line-clamp-2 group-hover:text-accent transition-colors">
{liveBlog.title}
</h4>
{getStatusBadge(liveBlog.status)}
</div>
{liveBlog.description && (
<p className="text-xs font-body text-muted-foreground mb-3 line-clamp-2">
{liveBlog.description}
</p>
)}
<div className="flex flex-wrap items-center gap-3 text-xs font-body 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}</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}</span>
</div>
)}
{liveBlog.author && (
<div className="text-xs text-muted-foreground">
{liveBlog.author.name}
</div>
)}
</div>
{liveBlog.featuredImage && (
<div className="mt-3">
<div className="relative h-20 border-2 border-foreground/10 overflow-hidden">
<img
src={liveBlog.featuredImage}
alt={liveBlog.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
</div>
</div>
)}
</div>
</Link>
))}
</div>
<div className="mt-6 pt-4 border-t-2 border-foreground/10">
<Link to="/live-blogs" className="block">
<Button variant="brutal" className="w-full justify-center">
Сите Live
</Button>
</Link>
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
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, Zap } from 'lucide-react';
const mkMonths = ['Јануари', 'Февруари', 'Март', 'Април', 'Мај', 'Јуни', 'Јули', 'Август', 'Септември', 'Октомври', 'Ноември', 'Декември'];
const mkWeekdays = ['Понеделник', 'Вторник', 'Среда', 'Четврток', 'Петок', 'Сабота', 'Недела'];
const formatDateMk = () => {
const d = new Date();
return `${mkWeekdays[d.getDay()]}, ${d.getDate()} ${mkMonths[d.getMonth()]} ${d.getFullYear()}`;
};
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' },
];
return (
<header className="sticky top-0 z-50 border-b-4 border-foreground bg-[hsl(var(--background))]">
<div className="border-b-2 border-foreground/20">
<div className="container mx-auto max-w-6xl px-4">
<div className="flex items-center justify-between py-2">
<div className="hidden md:flex items-center gap-2 text-xs font-mono uppercase tracking-wider text-muted-foreground">
<Zap className="w-3 h-3" />
<span>Сатирични вести од Македонија</span>
</div>
<div className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
{formatDateMk()}
</div>
</div>
</div>
</div>
<div className="container mx-auto max-w-6xl px-4 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="group">
<h1 className="text-4xl md:text-5xl font-display tracking-tight">
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1">P</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-75">l</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-100">a</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-150">c</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-200">e</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-250">b</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0 delay-300">o</span>
<span className="text-accent inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-350">.</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-1 delay-400">m</span>
<span className="inline-block transition-transform group-hover:-translate-y-1 group-hover:translate-x-0.5 delay-500">k</span>
</h1>
</Link>
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link, index) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-transparent hover:border-foreground hover:bg-accent hover:text-foreground transition-all duration-150"
style={{ animationDelay: `${index * 50}ms` }}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="px-3 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent transition-all duration-150"
>
{link.label}
</Link>
))}
</>
)}
<div className="flex items-center gap-3 ml-4 pl-4 border-l-2 border-foreground/20">
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username}
</span>
<Button
variant="brutal"
size="sm"
onClick={logout}
>
OUT
</Button>
</div>
</>
) : (
<Link to="/auth" className="px-4 py-2 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 ml-2">
Login
</Link>
)}
<div className="ml-4 pl-4 border-l-2 border-foreground/20 flex items-center gap-3">
<a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="hidden lg:block border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-3 py-1 font-body text-xs font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
<ThemeToggle />
</div>
</div>
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<Button
variant="brutal"
size="icon"
onClick={toggleMobileMenu}
className="h-10 w-10"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{isMobileMenuOpen && (
<div className="md:hidden mt-4 pt-4 border-t-4 border-foreground animate-scale-in">
<div className="flex flex-col gap-2">
{navLinks.map((link, index) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground hover:bg-accent hover:border-accent transition-all duration-150"
style={{ animationDelay: `${index * 50}ms` }}
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
{isAuthenticated ? (
<>
{(hasRole('admin') || hasRole('contributor')) && (
<div className="pt-3 mt-2 border-t-4 border-foreground">
<p className="font-body text-xs uppercase tracking-wider text-muted-foreground mb-2 pl-2">Admin</p>
{adminLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-accent bg-accent hover:bg-foreground hover:text-accent transition-all duration-150 block"
onClick={closeMobileMenu}
>
{link.label}
</Link>
))}
</div>
)}
<div className="pt-3 mt-2 border-t-4 border-foreground">
<div className="flex items-center justify-between p-2">
<span className="font-body text-xs uppercase tracking-wider text-muted-foreground">
{user?.username}
</span>
<Button
variant="brutal"
size="sm"
onClick={() => {
logout();
closeMobileMenu();
}}
>
OUT
</Button>
</div>
</div>
</>
) : (
<Link
to="/auth"
className="px-4 py-3 font-body text-sm font-medium uppercase tracking-wider border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent transition-all duration-150 mt-2"
onClick={closeMobileMenu}
>
Login
</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,16 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs'; import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles'; import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle, useUpdateArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs'; import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
// State for confirmation dialog and filters
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete'); const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{ const [itemToDelete, setItemToDelete] = useState<{
@@ -35,14 +32,11 @@ export function AdminDashboardComponent() {
const archiveLiveBlogMutation = useArchiveLiveBlog(); const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle(); const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog(); const publishLiveBlogMutation = usePublishLiveBlog();
const updateArticleMutation = useUpdateArticle();
const liveBlogs = liveBlogsData?.data || []; const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || []; const articles = articlesData?.data || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => { const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title }); setItemToDelete({ type, id, title });
setDialogType('delete'); setDialogType('delete');
@@ -81,7 +75,7 @@ export function AdminDashboardComponent() {
} else { } else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id); await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
} }
} else { // archive } else {
if (itemToDelete.type === 'article') { if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id); await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else { } else {
@@ -102,18 +96,32 @@ export function AdminDashboardComponent() {
setItemToDelete(null); 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) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'published': case 'published':
case 'live': case 'live':
return 'bg-green-100 text-green-800 border-green-200'; return 'bg-green-500 text-white border-2 border-foreground';
case 'draft': case 'draft':
return 'bg-yellow-100 text-yellow-800 border-yellow-200'; return 'bg-yellow-400 text-black border-2 border-foreground';
case 'archived': case 'archived':
case 'ended': case 'ended':
return 'bg-gray-100 text-gray-800 border-gray-200'; return 'bg-gray-400 text-white border-2 border-foreground';
default: default:
return 'bg-blue-100 text-blue-800 border-blue-200'; return 'bg-blue-500 text-white border-2 border-foreground';
} }
}; };
@@ -129,110 +137,125 @@ export function AdminDashboardComponent() {
}; };
return ( return (
<div className="py-8 space-y-8"> <div className="space-y-8">
<div className="flex justify-between items-center"> <div className="border-b-4 border-foreground pb-6">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Администраторски панел</h1> <h1 className="text-4xl font-display uppercase tracking-tight">Администраторски панел</h1>
<p className="text-muted-foreground"> <p className="font-body text-muted-foreground mt-1">
Управување со сите написи и live блогови Управување со сите написи и live блогови
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-3 flex-wrap">
<Button <Button
variant={showArchived ? "default" : "outline"} variant={showArchived ? 'brutal' : 'brutalOutline'}
onClick={() => setShowArchived(!showArchived)} onClick={() => setShowArchived(!showArchived)}
> >
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'} {showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button> </Button>
<Button asChild variant="outline"> <Button asChild variant="brutalAccent">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link> <Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button> </Button>
<Button asChild> <Button asChild variant="brutalOutline">
<Link to="/">Назад кон сајтот</Link> <Link to="/">Назад кон сајтот</Link>
</Button> </Button>
</div> </div>
</div> </div>
</div>
{!showArchived && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{articles.filter(a => a.status === 'published').length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{liveBlogs.filter(b => b.isPinned).length || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
</div>
<div className="border-brutal bg-card p-5">
<div className="text-4xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Live Blogs Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани Live блогови' : 'Live блогови'}
<span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredLiveBlogs.length || 0} {liveBlogs.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите live блогови со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingLiveBlogs ? ( {loadingLiveBlogs ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredLiveBlogs.length === 0 ? ( ) : liveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'} {showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p> </p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> liveBlogs.map((blog) => (
{filteredLiveBlogs.map((blog) => ( <div key={blog.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div <div className="flex items-start justify-between gap-3">
key={blog.id} <div className="flex-1 min-w-0">
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" <div className="flex items-center gap-2 mb-2 flex-wrap">
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link <Link
to="/admin/live-blogs/$slug" to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }} params={{ slug: blog.slug }}
className="font-medium hover:text-primary hover:underline" className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
> >
{blog.title} {blog.title}
</Link> </Link>
<Badge variant="outline" className={getStatusColor(blog.status)}> <span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(blog.status)}`}>
{getStatusText(blog.status)} {getStatusText(blog.status)}
</Badge> </span>
{blog.isPinned && ( {blog.isPinned && (
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-200"> <span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
Закачено Закачено
</Badge> </span>
)} )}
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<span>Слаг: {blog.slug}</span> <span>Слаг: {blog.slug}</span>
<span>•</span> <span>•</span>
<span> <span>{format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
Креирано: {format(new Date(blog.createdAt), 'dd MMM yyyy', { locale: mk })}
</span>
<span>•</span> <span>•</span>
<span>Прегледи: {blog.viewCount}</span> <span>Прегледи: {blog.viewCount}</span>
</div> </div>
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-1 flex-shrink-0">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="brutalOutline">
<Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link> <Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank"> <Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">Преглед</Link>
Преглед
</Link>
</Button> </Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handlePublishClick('liveBlog', blog.id)} onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -241,7 +264,7 @@ export function AdminDashboardComponent() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)} onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -259,91 +282,91 @@ export function AdminDashboardComponent() {
</div> </div>
</div> </div>
</div> </div>
))} ))
</div>
)} )}
</CardContent> </div>
</Card> </div>
{/* Articles Section */} <div className="border-brutal bg-card">
<Card> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
<CardHeader> <h2 className="text-2xl font-display uppercase">
<CardTitle className="flex items-center justify-between"> {showArchived ? 'Архивирани написи' : 'Написи'}
<span>{showArchived ? 'Архивирани написи' : 'Написи'}</span> </h2>
<Badge variant="outline" className="ml-2"> <span className="px-3 py-1 border-2 border-foreground bg-foreground text-background font-body text-sm font-bold uppercase">
{filteredArticles.length || 0} {articles.length || 0}
</Badge> </span>
</CardTitle> </div>
<CardDescription> <div className="p-4 space-y-3 max-h-[500px] overflow-y-auto">
Сите написи со статус и датум на креирање
</CardDescription>
</CardHeader>
<CardContent>
{loadingArticles ? ( {loadingArticles ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : filteredArticles.length === 0 ? ( ) : articles.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border-2 border-dashed border-foreground/30">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'} {showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p> </p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link>
</Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> articles.map((article) => (
{filteredArticles.map((article) => ( <div key={article.id} className="p-4 border-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div <div className="flex items-start justify-between gap-3">
key={article.id} <div className="flex-1 min-w-0">
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" <div className="flex items-center gap-2 mb-2 flex-wrap">
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link <Link
to="/articles/$id" to="/articles/$id"
params={{ id: article.id }} params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline" className="font-display text-lg hover:underline hover:text-accent-foreground truncate"
> >
{article.title} {article.title}
</Link> </Link>
<Badge variant="outline" className={getStatusColor(article.status)}> <span className={`px-2 py-0.5 font-body text-xs font-bold uppercase ${getStatusColor(article.status)}`}>
{getStatusText(article.status)} {getStatusText(article.status)}
</Badge> </span>
{article.isHero && (
<span className="px-2 py-0.5 font-body text-xs font-bold uppercase bg-yellow-400 text-black border-2 border-foreground">
★ Hero
</span>
)}
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs font-body text-muted-foreground flex-wrap">
<span>Слаг: {article.slug}</span> <span>Слаг: {article.slug}</span>
<span>•</span> <span>•</span>
<span> <span>{format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}</span>
Креирано: {format(new Date(article.createdAt), 'dd MMM yyyy', { locale: mk })}
</span>
<span>•</span> <span>•</span>
<span>Прегледи: {article.views}</span> <span>Прегледи: {article.views}</span>
<span>•</span>
<span>Shares: {(article.facebookShares || 0) + (article.twitterShares || 0) + (article.whatsappShares || 0) + (article.telegramShares || 0)}</span>
</div> </div>
{article.excerpt && ( {article.excerpt && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2"> <p className="mt-2 text-sm font-body text-muted-foreground line-clamp-2">
{article.excerpt} {article.excerpt}
</p> </p>
)} )}
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-1 flex-shrink-0 flex-col">
<Button asChild size="sm" variant="outline"> <div className="flex gap-1">
<Button asChild size="sm" variant="brutalOutline">
<Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link> <Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to="/articles/$id" params={{ id: article.id }} target="_blank"> <Link to="/articles/$id" params={{ id: article.id }} target="_blank">Преглед</Link>
Преглед </Button>
</Link> </div>
<div className="flex gap-1">
<Button
size="sm"
variant={article.isHero ? 'brutal' : 'brutalOutline'}
onClick={() => handleSetHero(article.id, !article.isHero)}
disabled={isProcessing || updateArticleMutation.isPending}
>
{article.isHero ? '★ Hero' : 'Set Hero'}
</Button> </Button>
{showArchived ? ( {showArchived ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handlePublishClick('article', article.id)} onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -352,7 +375,7 @@ export function AdminDashboardComponent() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="brutalOutline"
onClick={() => handleArchiveClick('article', article.id, article.title)} onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -370,70 +393,136 @@ export function AdminDashboardComponent() {
</div> </div>
</div> </div>
</div> </div>
))}
</div> </div>
))
)} )}
</CardContent> </div>
</Card> </div>
</div> </div>
{/* Quick Stats - Only show when not viewing archived items */}
{!showArchived && ( {!showArchived && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="border-brutal bg-card p-6">
<Card> <h2 className="text-2xl font-display uppercase mb-6">Social Media Analytics</h2>
<CardContent className="pt-6"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="text-2xl font-bold"> <div className="border-brutal-sm bg-background p-4">
{liveBlogs.filter(b => b.status === 'live').length || 0} <div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.facebookShares || 0), 0) || 0}
</div> </div>
<p className="text-sm text-muted-foreground">Активни live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">Facebook Shares</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{articles.filter(a => a.status === 'published').length || 0}
</div> </div>
<p className="text-sm text-muted-foreground">Објавени написи</p> <div className="border-brutal-sm bg-background p-4">
</CardContent> <div className="text-3xl font-display">
</Card> {articles.reduce((sum, a) => sum + (a.twitterShares || 0), 0) || 0}
<Card> </div>
<CardContent className="pt-6"> <p className="text-xs font-body text-muted-foreground mt-1">Twitter Shares</p>
<div className="text-2xl font-bold"> </div>
{liveBlogs.filter(b => b.isPinned).length || 0} <div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.whatsappShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">WhatsApp Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) => sum + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Telegram Shares</p>
</div>
<div className="border-brutal-sm bg-background p-4">
<div className="text-3xl font-display">
{articles.reduce((sum, a) =>
sum + (a.facebookShares || 0) + (a.twitterShares || 0) +
(a.whatsappShares || 0) + (a.telegramShares || 0), 0) || 0}
</div>
<p className="text-xs font-body text-muted-foreground mt-1">Total Shares</p>
</div>
</div>
<div>
<h3 className="text-xl font-display uppercase 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-brutal-sm bg-background hover:bg-accent/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link
to="/articles/$id"
params={{ id: article.id }}
className="font-display text-lg hover:underline hover:text-accent-foreground"
>
{article.title}
</Link>
<div className="flex items-center gap-3 text-xs font-body text-muted-foreground mt-2 flex-wrap">
<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 flex-wrap">
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-blue-600"></span>
FB: {article.facebookShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-sky-500"></span>
X: {article.twitterShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500"></span>
WA: {article.whatsappShares || 0}
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-cyan-500"></span>
TG: {article.telegramShares || 0}
</span>
</div>
</div>
</div>
</div>
);
})}
</div> </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> </div>
<p className="text-sm text-muted-foreground">Вкупно прегледи</p>
</CardContent>
</Card>
</div> </div>
)} )}
{/* Confirmation Dialog */}
{showConfirmDialog && itemToDelete && ( {showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4"> <div className="border-brutal bg-background p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2"> <h3 className="text-2xl font-display uppercase mb-3">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'} {dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3> </h3>
<p className="text-muted-foreground mb-4"> <p className="font-body text-muted-foreground mb-6">
{dialogType === 'delete' {dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?` ? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`} : `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p> </p>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-3">
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}> <Button variant="brutalOutline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи Откажи
</Button> </Button>
<Button <Button
variant={dialogType === 'delete' ? 'destructive' : 'default'} variant={dialogType === 'delete' ? 'destructive' : 'brutal'}
onClick={handleConfirmAction} onClick={handleConfirmAction}
disabled={isProcessing} disabled={isProcessing}
> >
@@ -447,4 +536,4 @@ export function AdminDashboardComponent() {
)} )}
</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="Уметност, култура и забава"
/>
)
}
@@ -5,6 +5,9 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { YouTubeEmbed } from '@/components/ui/youtube-embed' import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils' 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 }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -69,6 +72,26 @@ export function ArticleDetailComponent({ id }: { id: string }) {
)} )}
</div> </div>
{/* Social Sharing */}
<div className="mb-8 flex flex-wrap items-center gap-4">
<SocialShareButtons
articleId={data.id}
title={data.title}
url={typeof window !== 'undefined' ? window.location.href : ''}
excerpt={data.excerpt}
image={data.featuredImage}
tags={data.tags}
/>
<a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
</div>
{data.featuredImage && data.imagePosition !== 'none' && ( {data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${ <div className={`relative mb-4 ${
data.imagePosition === 'top' data.imagePosition === 'top'
@@ -166,6 +189,32 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
</div> </div>
{/* Social Sharing Footer */}
<div className="mt-8 pt-8 border-t">
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-muted-foreground mb-2">Share this article:</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<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"
/>
<a
href="https://www.buymeacoffee.com/placebomk"
target="_blank"
rel="noopener noreferrer"
className="border-2 border-foreground bg-yellow-400 hover:bg-yellow-300 transition-colors px-4 py-2 font-body text-sm font-bold uppercase tracking-wider"
>
☕ Купи ми кафе
</a>
</div>
</div>
</div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
<div className="mt-8 pt-8 border-t"> <div className="mt-8 pt-8 border-t">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3> <h3 className="text-sm font-semibold mb-4 text-muted-foreground">Tags</h3>
@@ -181,6 +230,17 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
</div> </div>
)} )}
{/* Reactions */}
<div className="mt-8 pt-8 border-t">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Што мислите за овој напис?</h3>
<ReactionButtons articleId={data.id} />
</div>
</div>
{/* Comments */}
<CommentSection articleId={data.id} />
</article> </article>
) )
} }
@@ -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,8 @@ export function LiveBlogsComponent() {
{data?.data.map((liveBlog) => ( {data?.data.map((liveBlog) => (
<Link <Link
key={liveBlog.id} key={liveBlog.id}
to={`/live-blogs/${liveBlog.slug}` as any} to="/live-blogs/$slug"
params={{ slug: liveBlog.slug }}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block" className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow cursor-pointer block"
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
@@ -0,0 +1,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 type { 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,
};
}
+11 -11
View File
@@ -1,25 +1,25 @@
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
export const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap font-body text-sm font-medium uppercase tracking-wider ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border-2 border-foreground bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
outline: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
brutal: "border-2 border-foreground bg-foreground text-background hover:bg-accent hover:text-foreground hover:border-accent shadow-brutal-sm",
brutalAccent: "border-2 border-accent bg-accent text-foreground hover:bg-foreground hover:text-accent shadow-brutal-accent",
brutalOutline: "border-2 border-foreground bg-transparent hover:bg-foreground hover:text-accent",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-11 px-6 py-2",
sm: "h-9 rounded-md px-3", sm: "h-9 px-4 py-1",
lg: "h-11 rounded-md px-8", lg: "h-14 px-8 text-base",
icon: "h-10 w-10", icon: "h-12 w-12",
}, },
}, },
defaultVariants: { defaultVariants: {
+5 -5
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "border-brutal bg-card text-card-foreground",
className className
)} )}
{...props} {...props}
@@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6 border-b-2 border-foreground/10", className)}
{...props} {...props}
/> />
)) ))
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-display leading-none tracking-tight",
className className
)} )}
{...props} {...props}
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm font-body text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
@@ -69,7 +69,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0 border-t-2 border-foreground/10", className)}
{...props} {...props}
/> />
)) ))
+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;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
export interface LiveBlogStreamEvent { export interface LiveBlogStreamEvent {
type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error'; type: 'connected' | 'update' | 'status-change' | 'pin-update' | 'error';
@@ -16,13 +16,13 @@ export function useLiveBlogStream(
liveBlogId: string, liveBlogId: string,
options: LiveBlogStreamOptions = {} options: LiveBlogStreamOptions = {}
) { ) {
const defaultOptions: LiveBlogStreamOptions = { const defaultOptions = useMemo((): LiveBlogStreamOptions => ({
autoReconnect: true, autoReconnect: true,
reconnectInterval: 3000, reconnectInterval: 3000,
maxReconnectAttempts: 10, maxReconnectAttempts: 10,
}; }), []);
const mergedOptions = { ...defaultOptions, ...options }; const mergedOptions = useMemo(() => ({ ...defaultOptions, ...options }), [options, defaultOptions]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null); const [lastEvent, setLastEvent] = useState<LiveBlogStreamEvent | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
+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,
};
}
+41 -41
View File
@@ -1,51 +1,51 @@
/* @tailwind base; */ @tailwind base;
/* @tailwind components; */ @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 35 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 0 0% 4%;
--card: 0 0% 100%; --card: 35 20% 95%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 0 0% 4%;
--popover: 0 0% 100%; --popover: 35 20% 95%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 0 0% 4%;
--primary: 222.2 47.4% 11.2%; --primary: 0 0% 4%;
--primary-foreground: 210 40% 98%; --primary-foreground: 35 20% 95%;
--secondary: 210 40% 96.1%; --secondary: 35 15% 88%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 4%;
--muted: 210 40% 96.1%; --muted: 35 10% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%; --accent: 70 100% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 4%;
--destructive: 0 84.2% 60.2%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 0 0% 4%;
--input: 214.3 31.8% 91.4%; --input: 0 0% 4%;
--ring: 222.2 84% 4.9%; --ring: 70 100% 50%;
--radius: 0.5rem; --radius: 0px;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 0 0% 8%;
--foreground: 210 40% 98%; --foreground: 35 20% 95%;
--card: 222.2 84% 4.9%; --card: 0 0% 12%;
--card-foreground: 210 40% 98%; --card-foreground: 35 20% 95%;
--popover: 222.2 84% 4.9%; --popover: 0 0% 12%;
--popover-foreground: 210 40% 98%; --popover-foreground: 35 20% 95%;
--primary: 210 40% 98%; --primary: 35 20% 95%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 0 0% 8%;
--secondary: 217.2 32.6% 17.5%; --secondary: 0 0% 15%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 35 20% 95%;
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 15%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%; --accent: 70 100% 50%;
--accent-foreground: 210 40% 98%; --accent-foreground: 0 0% 8%;
--destructive: 0 62.8% 30.6%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%; --border: 35 20% 95%;
--input: 217.2 32.6% 17.5%; --input: 35 20% 95%;
--ring: 212.7 26.8% 83.9%; --ring: 70 100% 50%;
} }
} }
+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;
}
};
+384 -42
View File
@@ -4,6 +4,42 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/
console.log('API_BASE_URL:', API_BASE_URL); console.log('API_BASE_URL:', API_BASE_URL);
console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL); console.log('VITE_API_URL env:', import.meta.env.VITE_API_URL);
// Helper function to get auth headers
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('token');
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
// Enhanced fetch wrapper
async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
const headers = getAuthHeaders();
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers,
},
});
// Handle 401 unauthorized
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/auth';
}
return response;
}
export interface Article { export interface Article {
id: string; id: string;
title: string; title: string;
@@ -37,6 +73,18 @@ export interface Article {
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isHero?: boolean;
isPinned?: boolean;
facebookShares?: number;
twitterShares?: number;
whatsappShares?: number;
telegramShares?: number;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
twitterTitle?: string;
twitterDescription?: string;
twitterImage?: string;
} }
export interface ArticlesResponse { export interface ArticlesResponse {
@@ -90,6 +138,7 @@ export interface UpdateArticleDto {
videoUrl?: string; videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none'; videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string; videoCaption?: string;
isHero?: boolean;
} }
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> { export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
@@ -110,7 +159,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
const url = `${API_BASE_URL}/articles?${searchParams}`; const url = `${API_BASE_URL}/articles?${searchParams}`;
console.log('Fetching from:', url); console.log('Fetching from:', url);
const response = await fetch(url); const response = await authFetch(url);
console.log('Response status:', response.status, 'ok:', response.ok); console.log('Response status:', response.status, 'ok:', response.ok);
if (!response.ok) { if (!response.ok) {
@@ -123,7 +172,7 @@ export async function fetchArticles(params: FindArticlesParams = {}): Promise<Ar
} }
export async function fetchArticleBySlug(slug: string): Promise<Article> { export async function fetchArticleBySlug(slug: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/slug/${slug}`); const response = await authFetch(`${API_BASE_URL}/articles/slug/${slug}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch article'); throw new Error('Failed to fetch article');
} }
@@ -131,7 +180,7 @@ export async function fetchArticleBySlug(slug: string): Promise<Article> {
} }
export async function fetchArticleById(id: string): Promise<Article> { export async function fetchArticleById(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`); const response = await authFetch(`${API_BASE_URL}/articles/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch article'); throw new Error('Failed to fetch article');
} }
@@ -139,11 +188,8 @@ export async function fetchArticleById(id: string): Promise<Article> {
} }
export async function createArticle(dto: CreateArticleDto): Promise<Article> { export async function createArticle(dto: CreateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles`, { const response = await authFetch(`${API_BASE_URL}/articles`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -153,11 +199,8 @@ export async function createArticle(dto: CreateArticleDto): Promise<Article> {
} }
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> { export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, { const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -167,7 +210,7 @@ export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<
} }
export async function deleteArticle(id: string): Promise<void> { export async function deleteArticle(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, { const response = await authFetch(`${API_BASE_URL}/articles/${id}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
@@ -176,7 +219,7 @@ export async function deleteArticle(id: string): Promise<void> {
} }
export async function archiveArticle(id: string): Promise<Article> { export async function archiveArticle(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, { const response = await authFetch(`${API_BASE_URL}/articles/${id}/archive`, {
method: 'PATCH', method: 'PATCH',
}); });
if (!response.ok) { if (!response.ok) {
@@ -186,7 +229,7 @@ export async function archiveArticle(id: string): Promise<Article> {
} }
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> { export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, { const response = await authFetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
method: 'PATCH', method: 'PATCH',
}); });
if (!response.ok) { if (!response.ok) {
@@ -323,15 +366,19 @@ export async function fetchLiveBlogs(params: FindLiveBlogsParams = {}): Promise<
} }
}); });
const response = await fetch(`${API_BASE_URL}/live-blogs?${searchParams}`); const response = await authFetch(`${API_BASE_URL}/live-blogs?${searchParams}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blogs'); throw new Error('Failed to fetch live blogs');
} }
return response.json(); return response.json();
} }
export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> { export async function fetchLiveBlogBySlug(slugOrId: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/slug/${slug}`); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
const endpoint = isUuid
? `${API_BASE_URL}/live-blogs/${slugOrId}`
: `${API_BASE_URL}/live-blogs/slug/${slugOrId}`;
const response = await authFetch(endpoint);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blog'); throw new Error('Failed to fetch live blog');
} }
@@ -339,7 +386,7 @@ export async function fetchLiveBlogBySlug(slug: string): Promise<LiveBlog> {
} }
export async function fetchLiveBlogById(id: string): Promise<LiveBlog> { export async function fetchLiveBlogById(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`); const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch live blog'); throw new Error('Failed to fetch live blog');
} }
@@ -351,7 +398,7 @@ export async function fetchLiveBlogUpdates(
page = 1, page = 1,
limit = 50 limit = 50
): Promise<LiveBlogUpdatesResponse> { ): Promise<LiveBlogUpdatesResponse> {
const response = await fetch( const response = await authFetch(
`${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}` `${API_BASE_URL}/live-blogs/${liveBlogId}/updates?page=${page}&limit=${limit}`
); );
if (!response.ok) { if (!response.ok) {
@@ -361,15 +408,32 @@ export async function fetchLiveBlogUpdates(
} }
export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> { export async function fetchRecentLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/recent`); const response = await authFetch(`${API_BASE_URL}/live-blogs/recent`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch recent live blogs'); throw new Error('Failed to fetch recent live blogs');
} }
return response.json(); 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[]> { export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/featured`); const response = await authFetch(`${API_BASE_URL}/live-blogs/featured`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch pinned live blogs'); throw new Error('Failed to fetch pinned live blogs');
} }
@@ -377,7 +441,7 @@ export async function fetchPinnedLiveBlogs(): Promise<LiveBlog[]> {
} }
export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> { export async function fetchActiveLiveBlogs(): Promise<LiveBlog[]> {
const response = await fetch(`${API_BASE_URL}/live-blogs/active`); const response = await authFetch(`${API_BASE_URL}/live-blogs/active`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch active live blogs'); throw new Error('Failed to fetch active live blogs');
} }
@@ -389,11 +453,8 @@ export async function createLiveBlogUpdate(
liveBlogId: string, liveBlogId: string,
dto: CreateLiveBlogUpdateDto dto: CreateLiveBlogUpdateDto
): Promise<LiveBlogUpdate> { ): Promise<LiveBlogUpdate> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -407,11 +468,8 @@ export async function updateLiveBlogUpdate(
updateId: string, updateId: string,
dto: UpdateLiveBlogUpdateDto dto: UpdateLiveBlogUpdateDto
): Promise<LiveBlogUpdate> { ): Promise<LiveBlogUpdate> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -421,7 +479,7 @@ export async function updateLiveBlogUpdate(
} }
export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> { export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${liveBlogId}/updates/${updateId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
@@ -430,11 +488,8 @@ export async function deleteLiveBlogUpdate(liveBlogId: string, updateId: string)
} }
export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> { export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs`, { const response = await authFetch(`${API_BASE_URL}/live-blogs`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -444,11 +499,8 @@ export async function createLiveBlog(dto: CreateLiveBlogDto): Promise<LiveBlog>
} }
export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> { export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
method: 'PUT', method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto), body: JSON.stringify(dto),
}); });
if (!response.ok) { if (!response.ok) {
@@ -458,7 +510,7 @@ export async function updateLiveBlog(id: string, dto: UpdateLiveBlogDto): Promis
} }
export async function deleteLiveBlog(id: string): Promise<void> { export async function deleteLiveBlog(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
@@ -467,7 +519,7 @@ export async function deleteLiveBlog(id: string): Promise<void> {
} }
export async function archiveLiveBlog(id: string): Promise<LiveBlog> { export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
method: 'PATCH', method: 'PATCH',
}); });
if (!response.ok) { if (!response.ok) {
@@ -477,7 +529,7 @@ export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
} }
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> { export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, { const response = await authFetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
method: 'PATCH', method: 'PATCH',
}); });
if (!response.ok) { if (!response.ok) {
@@ -485,3 +537,293 @@ export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'en
} }
return response.json(); 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;
parentId?: 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 = {
id: comment.id,
content: comment.content,
articleId: comment.articleId,
liveBlogId: comment.liveBlogId,
parentCommentId: comment.parentId,
userId: comment.userId,
isVisible: comment.isVisible,
reactions: {
likes: comment.likeCount || 0,
dislikes: comment.dislikeCount || 0,
},
replies: comment.replies?.map(mapBackendComment) || [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
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);
}
+7
View File
@@ -3,13 +3,20 @@ import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router' import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme'
// Initialize theme before rendering
initializeTheme();
const queryClient = new QueryClient() const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
) )
+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
}]
});
},
});
}
+203 -154
View File
@@ -1,59 +1,79 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { ArticleTicker } from './components/ArticleTicker' import { ArticleTicker } from './components/ArticleTicker'
import { ArticlesComponent } from './components/routes/ArticlesComponent' import { ArchiveComponent } from './components/routes/ArchiveComponent'
import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent' import { ArticleDetailComponent } from './components/routes/ArticleDetailComponent'
import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent' import { LiveBlogsComponent } from './components/routes/LiveBlogsComponent'
import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent' import { LiveBlogDetailComponent } from './components/routes/LiveBlogDetailComponent'
import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent' import { LiveBlogAdminComponent } from './components/routes/LiveBlogAdminComponent'
import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent' import { CreateLiveBlogComponent } from './components/routes/CreateLiveBlogComponent'
import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent' import { AdminDashboardComponent } from './components/routes/AdminDashboardComponent'
import { LiveBlogTicker } from './components/features/live-blog/LiveBlogTicker' import { AuthPage } from './components/routes/AuthPage'
import { PinnedLiveBlogSidebar } from './components/features/live-blog/PinnedLiveBlogSidebar' import { SportComponent } from './components/routes/SportComponent'
import { ArtComponent } from './components/routes/ArtComponent'
import { ScienceComponent } from './components/routes/ScienceComponent'
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 { Button } from './components/ui/button'
import { Zap, Search, Users } from 'lucide-react'
import './styles.css' import './styles.css'
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
head: () => ({ head: () => ({
meta: [ meta: [
{ {
title: 'Placebo.mk - Sarcastic News from Macedonia', title: 'Placebo.mk - Сатирични вести од Македонија',
description: 'Latest news and articles from Macedonia with a sarcastic twist', description: 'Latest news and articles from Macedonia with a sarcastic twist',
}, },
], ],
}), }),
component: () => ( component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col"> <div className="min-h-screen bg-background text-foreground flex flex-col">
<header className="border-b"> <Header />
<div className="container mx-auto max-w-6xl px-4 py-4"> <ArticleTicker />
<h1 className="text-3xl font-bold">
<Link to="/" className="hover:underline">Placebo.mk</Link>
</h1>
<nav className="flex gap-4">
<Link to="/" className="text-sm font-medium hover:underline">
Home
</Link>
<Link to="/articles" className="text-sm font-medium hover:underline">
Articles
</Link>
<Link to="/live-blogs" className="text-sm font-medium hover:underline">
Live
</Link>
<Link to="/admin" className="text-sm font-medium hover:underline text-primary">
Admin
</Link>
<Link to="/admin/live-blogs/create" className="text-sm font-medium hover:underline text-primary">
+ New Live Blog
</Link>
</nav>
</div>
</header>
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8"> <main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t mt-12"> <footer className="border-t-4 border-foreground bg-foreground text-background">
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground"> <div className="container mx-auto max-w-6xl px-4 py-12">
© 2025 Placebo.mk. Sarcastic news from Macedonia. <div className="grid md:grid-cols-3 gap-8">
<div>
<h3 className="font-display text-3xl mb-4">Placebo.mk</h3>
<p className="font-body text-sm text-background/70">
Непристојни сатрирични вести и коментари за локални и глобални настани во Македонија.
</p>
</div>
<div>
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Категории</h4>
<ul className="space-y-2 font-body text-sm">
<li><Link to="/sport" className="hover:text-accent transition-colors">Спорт</Link></li>
<li><Link to="/art" className="hover:text-accent transition-colors">Уметност</Link></li>
<li><Link to="/science" className="hover:text-accent transition-colors">Наука</Link></li>
<li><Link to="/archive" className="hover:text-accent transition-colors">Архива</Link></li>
</ul>
</div>
<div>
<h4 className="font-body text-sm font-bold uppercase tracking-wider mb-4 text-accent">Следете не</h4>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Zap className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Search className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 border-2 border-background flex items-center justify-center hover:bg-accent hover:text-foreground transition-colors">
<Users className="w-5 h-5" />
</a>
</div>
</div>
</div>
<div className="mt-12 pt-8 border-t border-background/20 text-center font-body text-xs uppercase tracking-wider">
© 2025 Placebo.mk — Сите права се заштитени. Или не се.
</div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -64,141 +84,72 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: () => ( component: () => (
<div className="space-y-6"> <div>
{/* Article Ticker at the top */}
<ArticleTicker />
{/* Live Blog Ticker below article ticker */}
<LiveBlogTicker className="mt-4" />
<div className="py-8 md:py-12"> <div className="py-8 md:py-12">
<div className="max-w-7xl mx-auto"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> <div className="lg:col-span-2">
{/* Main content - 3 columns */} <HeroArticle />
<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> </div>
{/* Features grid */} <div className="lg:col-span-1">
<div className="grid md:grid-cols-3 gap-6"> <PinnedLiveBlogsSidebar />
<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>
<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>
</div> </div>
{/* Call to action */} <LatestArticlesGrid />
<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> <div className="mt-16 border-4 border-foreground p-8 bg-foreground text-background animate-fade-in-up">
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto"> <div className="text-center">
Dive into our articles or follow live coverage of breaking news as it happens. <h2 className="text-4xl md:text-6xl font-display mb-4">Placebo.mk</h2>
<p className="font-body text-lg max-w-2xl mx-auto text-background/80 mb-8">
Непристојно сатрирични вести и коментари за локални и глобални настани во Македонија.
Затоа што понекогаш вистината боли повеќе од фикцијата.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link <Link to="/archive">
to="/articles" <Button variant="brutalAccent" className="gap-2">
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" Прелистај архива
> </Button>
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>
<Link <Link to="/live-blogs">
to="/live-blogs" <Button variant="brutalOutline" className="gap-2 text-background border-background hover:bg-background hover:text-foreground">
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" <Zap className="w-4 h-4" />
> Live Блогови
View Live Blogs </Button>
<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> </Link>
</div> </div>
</div> </div>
</div> </div>
{/* Sidebar - 1 column */} <div className="grid md:grid-cols-3 gap-6 mt-16">
<div className="lg:col-span-1"> <div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-1">
<PinnedLiveBlogSidebar /> <div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<Zap className="w-6 h-6" />
{/* Additional sidebar content */} </div>
<div className="mt-6 rounded-xl border bg-card p-6"> <h3 className="text-2xl font-display mb-2">Најнови вести</h3>
<h3 className="font-semibold mb-4">About Placebo.mk</h3> <p className="font-body text-sm text-muted-foreground">
<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> </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="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-2">
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<span className="font-display text-2xl">⚠</span>
</div> </div>
<h3 className="text-2xl font-display mb-2">Без филтер</h3>
<p className="font-body text-sm text-muted-foreground">
Не правиме нијанси. Не правиме дипломатски јазик. Само искрени (и малку лоши) коментари.
</p>
</div> </div>
<div className="border-brutal-sm bg-card p-6 hover:shadow-brutal transition-all duration-150 animate-fade-in-up stagger-3">
<div className="w-12 h-12 border-2 border-foreground flex items-center justify-center mb-4">
<Users className="w-6 h-6" />
</div> </div>
<h3 className="text-2xl font-display mb-2">Live Покривање</h3>
<p className="font-body text-sm text-muted-foreground">
Ажурирања во реално време за разбивачки вести со нашиот систем за live blogging. Нема одложувања, само факти.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -206,10 +157,28 @@ const indexRoute = createRoute({
), ),
}) })
const articlesRoute = createRoute({ const archiveRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/articles', path: '/archive',
component: ArticlesComponent, 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({ const articleDetailRoute = createRoute({
@@ -219,6 +188,64 @@ const articleDetailRoute = createRoute({
const { id } = articleDetailRoute.useParams() const { id } = articleDetailRoute.useParams()
return <ArticleDetailComponent id={id} /> return <ArticleDetailComponent id={id} />
}, },
loader: async ({ params }) => {
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' },
],
}
}
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 = [
{ title: `${article.title} - Placebo.mk` },
{ name: 'description', content: ogDescription },
{ 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' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: twitterTitle },
{ name: 'twitter:description', content: twitterDescription },
{ name: 'twitter:image', content: twitterImage },
{ property: 'article:published_time', content: article.createdAt },
{ property: 'article:modified_time', content: article.updatedAt },
]
if (article.author?.name) {
metaTags.push({ property: 'article:author', content: article.author.name })
}
if (article.tags && article.tags.length > 0) {
article.tags.forEach((tag: string) => {
metaTags.push({ property: 'article:tag', content: tag })
})
}
return {
meta: metaTags,
}
},
}) })
const liveBlogsRoute = createRoute({ const liveBlogsRoute = createRoute({
@@ -236,33 +263,55 @@ const liveBlogDetailRoute = createRoute({
}, },
}) })
const authRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth',
component: AuthPage,
})
const liveBlogAdminRoute = createRoute({ const liveBlogAdminRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/admin/live-blogs/$slug', path: '/admin/live-blogs/$slug',
component: () => { component: () => {
const { slug } = liveBlogAdminRoute.useParams() const { slug } = liveBlogAdminRoute.useParams()
return <LiveBlogAdminComponent slug={slug} /> return (
<ProtectedRoute requiredRole="admin">
<LiveBlogAdminComponent slug={slug} />
</ProtectedRoute>
)
}, },
}) })
const createLiveBlogRoute = createRoute({ const createLiveBlogRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/admin/live-blogs/create', path: '/admin/live-blogs/create',
component: CreateLiveBlogComponent, component: () => (
<ProtectedRoute requiredRole="admin">
<CreateLiveBlogComponent />
</ProtectedRoute>
),
}) })
const adminDashboardRoute = createRoute({ const adminDashboardRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/admin', path: '/admin',
component: AdminDashboardComponent, component: () => (
<ProtectedRoute requiredRole="admin">
<AdminDashboardComponent />
</ProtectedRoute>
),
}) })
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
articlesRoute, archiveRoute,
sportRoute,
artRoute,
scienceRoute,
articleDetailRoute, articleDetailRoute,
liveBlogsRoute, liveBlogsRoute,
liveBlogDetailRoute, liveBlogDetailRoute,
authRoute,
liveBlogAdminRoute, liveBlogAdminRoute,
createLiveBlogRoute, createLiveBlogRoute,
adminDashboardRoute, adminDashboardRoute,
+318 -61
View File
@@ -1,72 +1,162 @@
@import "tailwindcss"; @import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
@theme { @theme {
--color-primary: oklch(0.647 0.22 0.23); --color-primary: oklch(0.08 0 0);
--color-primary-foreground: oklch(0.985 0.002 0); --color-primary-foreground: oklch(0.98 0 0);
--color-secondary: oklch(0.97 0.002 0); --color-secondary: oklch(0.93 0.02 50);
--color-secondary-foreground: oklch(0.205 0.02 266.5); --color-secondary-foreground: oklch(0.08 0 0);
--color-muted: oklch(0.97 0 0); --color-muted: oklch(0.91 0.01 50);
--color-muted-foreground: oklch(0.556 0 0); --color-muted-foreground: oklch(0.45 0.01 50);
--color-accent: oklch(0.97 0 0); --color-accent: oklch(0.93 0.8 100);
--color-accent-foreground: oklch(0.205 0.02 266.5); --color-accent-foreground: oklch(0.08 0 0);
--color-destructive: oklch(0.55 0.22 25); --color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.985 0.002 0); --color-destructive-foreground: oklch(0.98 0 0);
--color-border: oklch(0.9 0 0); --color-border: oklch(0.08 0 0);
--color-input: oklch(0.9 0 0); --color-input: oklch(0.08 0 0);
--color-ring: oklch(0.647 0.22 0.23); --color-ring: oklch(0.93 0.8 100);
--radius: 0.5rem; --radius: 0px;
--font-sans: "Inter", sans-serif; --font-display: "Bebas Neue", sans-serif;
--font-body: "IBM Plex Mono", monospace;
} }
:root { :root {
--background: 0 0% 100%; --background: 35 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 0 0% 4%;
--card: 0 0% 100%; --card: 35 20% 95%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 0 0% 4%;
--popover: 0 0% 100%; --popover: 35 20% 95%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 0 0% 4%;
--primary: 222.2 47.4% 11.2%; --primary: 0 0% 4%;
--primary-foreground: 210 40% 98%; --primary-foreground: 35 20% 95%;
--secondary: 210 40% 96.1%; --secondary: 35 15% 88%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 4%;
--muted: 210 40% 96.1%; --muted: 35 10% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%; --accent: 70 100% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 4%;
--destructive: 0 84.2% 60.2%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 0 0% 4%;
--input: 214.3 31.8% 91.4%; --input: 0 0% 4%;
--ring: 222.2 84% 4.9%; --ring: 70 100% 50%;
--radius: 0.5rem; --radius: 0px;
--shadow-brutal: 4px 4px 0px 0px oklch(0.08 0 0);
--shadow-brutal-sm: 2px 2px 0px 0px oklch(0.08 0 0);
--shadow-brutal-lg: 6px 6px 0px 0px oklch(0.08 0 0);
--shadow-brutal-accent: 4px 4px 0px 0px oklch(0.93 0.8 100);
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 0 0% 8%;
--foreground: 210 40% 98%; --foreground: 35 20% 95%;
--card: 222.2 84% 4.9%; --card: 0 0% 12%;
--card-foreground: 210 40% 98%; --card-foreground: 35 20% 95%;
--popover: 222.2 84% 4.9%; --popover: 0 0% 12%;
--popover-foreground: 210 40% 98%; --popover-foreground: 35 20% 95%;
--primary: 210 40% 98%; --primary: 35 20% 95%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 0 0% 8%;
--secondary: 217.2 32.6% 17.5%; --secondary: 0 0% 15%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 35 20% 95%;
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 15%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%; --accent: 70 100% 50%;
--accent-foreground: 210 40% 98%; --accent-foreground: 0 0% 8%;
--destructive: 0 62.8% 30.6%; --destructive: 0 80% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%; --border: 35 20% 95%;
--input: 217.2 32.6% 17.5%; --input: 35 20% 95%;
--ring: 212.7 26.8% 83.9%; --ring: 70 100% 50%;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-behavior: smooth;
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-body);
position: relative;
}
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
::selection {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-border {
0%, 100% {
border-color: hsl(var(--border));
}
50% {
border-color: hsl(var(--accent));
}
}
@keyframes marquee { @keyframes marquee {
0% { 0% {
transform: translateX(0); transform: translateX(0);
@@ -76,18 +166,185 @@
} }
} }
@keyframes glitch {
0% {
clip-path: inset(40% 0 61% 0);
transform: translate(-2px, 2px);
}
20% {
clip-path: inset(92% 0 1% 0);
transform: translate(1px, -1px);
}
40% {
clip-path: inset(43% 0 1% 0);
transform: translate(-1px, 2px);
}
60% {
clip-path: inset(25% 0 58% 0);
transform: translate(2px, 1px);
}
80% {
clip-path: inset(54% 0 7% 0);
transform: translate(-2px, -1px);
}
100% {
clip-path: inset(58% 0 43% 0);
transform: translate(2px, 2px);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.animate-slide-in-left {
animation: slideInLeft 0.5s ease-out forwards;
opacity: 0;
}
.animate-slide-in-right {
animation: slideInRight 0.5s ease-out forwards;
opacity: 0;
}
.animate-scale-in {
animation: scaleIn 0.4s ease-out forwards;
opacity: 0;
}
.animate-marquee {
display: flex;
animation: marquee 25s linear infinite;
width: max-content;
}
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
.stagger-7 { animation-delay: 0.35s; }
.stagger-8 { animation-delay: 0.4s; }
.stagger-9 { animation-delay: 0.45s; }
.stagger-10 { animation-delay: 0.5s; }
.stagger-11 { animation-delay: 0.55s; }
.stagger-12 { animation-delay: 0.6s; }
.font-display {
font-family: var(--font-display);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.font-body {
font-family: var(--font-body);
}
.border-brutal {
border: 3px solid hsl(var(--border));
}
.border-brutal-sm {
border: 2px solid hsl(var(--border));
}
.border-brutal-accent {
border: 3px solid hsl(var(--accent));
border-left-width: 6px;
}
.shadow-brutal {
box-shadow: var(--shadow-brutal);
}
.shadow-brutal-sm {
box-shadow: var(--shadow-brutal-sm);
}
.shadow-brutal-lg {
box-shadow: var(--shadow-brutal-lg);
}
.shadow-brutal-accent {
box-shadow: var(--shadow-brutal-accent);
}
.hover\:shadow-brutal:hover {
box-shadow: var(--shadow-brutal);
transform: translate(-2px, -2px);
transition: all 0.15s ease;
}
.hover\:shadow-brutal-accent:hover {
box-shadow: var(--shadow-brutal-accent);
transform: translate(-2px, -2px);
transition: all 0.15s ease;
}
.bg-noise {
position: relative;
}
.bg-noise::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
}
.text-outline {
-webkit-text-stroke: 1px hsl(var(--foreground));
color: transparent;
}
.text-outline-hover:hover {
-webkit-text-stroke: 1px hsl(var(--foreground));
color: transparent;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@layer base { @layer base {
* { * {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
} }
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.animate-marquee { h1, h2, h3, h4, h5, h6 {
display: flex; font-family: var(--font-display);
animation: marquee 30s linear infinite; letter-spacing: 0.02em;
width: max-content; text-transform: uppercase;
} }
} }
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: hsl(var(--secondary));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border: 2px solid hsl(var(--secondary));
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent));
}
+32
View File
@@ -0,0 +1,32 @@
export type UserRole = 'admin' | 'contributor' | 'user';
export interface User {
id: string;
username: string;
email: string;
role: UserRole;
createdAt: string;
updatedAt: string;
}
export interface LoginDto {
username: string;
password: string;
}
export interface RegisterDto {
username: string;
email: string;
password: string;
}
export interface AuthResponse {
access_token: string;
user: User;
}
export interface ApiError {
message: string;
statusCode: number;
error?: string;
}
+1
View File
@@ -0,0 +1 @@
export type { User, UserRole, LoginDto, RegisterDto, AuthResponse, ApiError } from './auth';
+6
View File
@@ -13,6 +13,12 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
// For npm workspaces - look for dependencies in parent node_modules
preserveSymlinks: true,
},
// For npm workspaces - optimize dependencies from root
optimizeDeps: {
include: ['react', 'react-dom', 'react-markdown'],
}, },
server: { server: {
host: true, // Listen on all addresses host: true, // Listen on all addresses
+658 -499
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more